From 767afa48a884b4ceb4396ed6dc9775200ba5036e Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 6 Oct 2026 15:02:54 -0700 Subject: [PATCH 01/35] test: reproduce MCP clause schema mismatches RED: schema contract tests compile and execute on Java 21, with seven schema failures and one malformed OpenAPI example error. --- .../v1/mcp/McpClauseSchemaContractTest.java | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java new file mode 100644 index 0000000000..b55aed0286 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -0,0 +1,127 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.victools.jsonschema.generator.*; +import io.quarkiverse.mcp.server.GlobalInputSchemaGenerator; +import io.quarkiverse.mcp.server.ToolManager; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.FilterDefinition; +import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.SortDefinition; +import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; +import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; +import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import jakarta.inject.Inject; +import java.util.stream.Stream; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** Contracts between advertised MCP schemas and the actual Data API JSON deserializers. */ +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +class McpClauseSchemaContractTest { + @Inject ObjectMapper objectMapper; + @Inject GlobalInputSchemaGenerator schemaGenerator; + @Inject ToolManager toolManager; + + record WireNames( + @JsonProperty("wireName") String internalName, CommandFeatures commandFeatures) {} + + @Test + void fieldsUseJsonPropertyNamesAndHideCommandFeatures() { + var properties = schemaFor(WireNames.class).path("properties"); + assertTrue(properties.has("wireName")); + assertFalse(properties.has("internalName")); + assertFalse(properties.has("commandFeatures")); + } + + @ParameterizedTest + @MethodSource("clauseTypes") + void everyAdvertisedClauseExampleDeserializes(Class clauseType) throws Exception { + var schema = schemaFor(clauseType); + assertFalse(schema.toString().contains("commandFeatures")); + assertFalse(schema.toString().contains("filterClause")); + assertFalse(schema.toString().contains("sortClause")); + assertFalse(schema.toString().contains("vectorizeSort")); + var examples = schema.get("examples"); + assertNotNull(examples, "Clause schema must advertise examples of the accepted JSON"); + assertFalse(examples.isEmpty()); + for (var example : examples) { + assertNotNull(objectMapper.treeToValue(example, clauseType)); + } + } + + static Stream> clauseTypes() { + return Stream.of( + FilterDefinition.class, + SortDefinition.class, + FindAndRerankSort.class, + FindAndRerankCommand.HybridLimits.class); + } + + @Test + void rerankToolAdvertisesWireFormatAndOverrideName() throws Exception { + var schema = inputSchema("findAndRerank"); + var properties = schema.path("properties"); + assertTrue(properties.path("sort").path("properties").has("$hybrid")); + var options = properties.path("options").path("properties"); + assertTrue(options.has("rerank")); + assertFalse(options.has("rerankServiceOverride")); + assertTrue(options.path("hybridLimits").has("anyOf")); + assertFalse(schema.toString().contains("commandFeatures")); + assertFalse(schema.toString().contains("vectorLimit")); + + for (var example : properties.path("sort").path("examples")) { + assertNotNull(objectMapper.treeToValue(example, FindAndRerankSort.class)); + } + for (var example : options.path("hybridLimits").path("examples")) { + assertNotNull(objectMapper.treeToValue(example, FindAndRerankCommand.HybridLimits.class)); + } + } + + @Test + void findToolAdvertisesPlainFilterAndSortClauses() throws Exception { + var properties = inputSchema("find").path("properties"); + var filter = properties.path("filter"); + var sort = properties.path("sort"); + assertFalse(filter.has("properties")); + assertFalse(sort.toString().contains("sortClause")); + assertTrue(filter.path("examples").size() > 0); + assertTrue(sort.path("examples").size() > 0); + var filterExample = filter.path("examples").get(0); + var sortExample = sort.path("examples").get(0); + assertEquals( + filterExample, objectMapper.treeToValue(filterExample, FilterDefinition.class).json()); + assertEquals(sortExample, objectMapper.treeToValue(sortExample, SortDefinition.class).json()); + } + + @Test + void openApiSortExamplesAreValidClauseJson() throws Exception { + var schema = FindAndRerankSort.class.getAnnotation(Schema.class); + for (var example : schema.examples()) { + assertNotNull(objectMapper.readValue(example, FindAndRerankSort.class)); + } + } + + private JsonNode inputSchema(String name) throws Exception { + var tool = toolManager.getTool(name); + assertNotNull(tool); + return objectMapper.readTree(schemaGenerator.generate(tool).asJson()); + } + + private JsonNode schemaFor(Class type) { + var builder = + new SchemaGeneratorConfigBuilder( + objectMapper, SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) + .without(Option.SCHEMA_VERSION_INDICATOR); + new McpSchemaDescriptionCustomizer().customize(builder); + return new SchemaGenerator(builder.build()).generateSchema(type); + } +} From d82706671f562ae476753d3422999b2478b786c2 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 6 Oct 2026 15:07:59 -0700 Subject: [PATCH 02/35] fix: advertise Data API clause wire formats in MCP schemas GREEN: 124 MCP schema, parser, builder, and ObjectMapper tests pass on Java 21. Schema examples validate and deserialize, and rerank override fields remain scoped to the request. --- .../clause/sort/FindAndRerankSort.java | 11 +- .../command/impl/FindAndRerankCommand.java | 6 +- .../api/v1/mcp/CollectionCommandTools.java | 15 +- .../api/v1/mcp/McpClauseSchemaCustomizer.java | 157 ++++++++++++++++++ .../v1/mcp/McpClauseSchemaContractTest.java | 77 ++++++++- 5 files changed, 248 insertions(+), 18 deletions(-) create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java 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..4cccbea88a 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 @@ -21,12 +21,11 @@ @Schema( 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" }}} - """) + 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\"}}" + }) public record FindAndRerankSort( String vectorizeSort, String lexicalSort, float[] vectorSort, CommandFeatures commandFeatures) implements Recordable { 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..38ba5fc946 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 @@ -73,11 +73,7 @@ public record Options( @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}} - """) + examples = {"50", "{\"$vector\": 50, \"$lexical\": 10}"}) HybridLimits hybridLimits, /** ---- */ @Schema( 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..b2f5dd21f4 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 @@ -149,15 +149,22 @@ public Uni estimatedDocumentCount( } @Tool( - description = - "Finds documents using using vector and lexical sorting, then reranks the results.") + description = "Finds candidate documents using a $hybrid sort, then reranks their passages.") 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 = + "A $hybrid sort: query text, or an object using $vector or $vectorize and optionally $lexical.", + required = false) + FindAndRerankSort sort, + @ToolArg( + description = + "Reranking options: limit, hybridLimits, rerankQuery, rerankOn, includeScores, includeSortVector, and an optional rerank service override.", + 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/McpClauseSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java new file mode 100644 index 0000000000..91427e51df --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java @@ -0,0 +1,157 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.victools.jsonschema.generator.CustomDefinition; +import com.github.victools.jsonschema.generator.CustomPropertyDefinition; +import com.github.victools.jsonschema.generator.SchemaGenerationContext; +import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; +import io.quarkiverse.mcp.server.runtime.SchemaGeneratorConfigCustomizer; +import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.FilterDefinition; +import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.SortDefinition; +import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; +import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; +import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; +import jakarta.inject.Singleton; +import java.util.Map; + +/** + * Advertises the Data API wire format for clauses whose JSON shape differs from their Java fields. + * Custom deserializers and delegating creators cannot be described by reflecting their fields. + */ +@Singleton +public class McpClauseSchemaCustomizer implements SchemaGeneratorConfigCustomizer { + private static final Map, String> CLAUSE_SCHEMAS = + Map.of( + FilterDefinition.class, + """ + { + "type": ["object", "null"], + "description": "A Data API filter object mapping document paths to values or operator expressions, such as $eq, $gt, $in, $and, and $or.", + "additionalProperties": true, + "examples": [{"age": {"$gt": 40}}, {"name": "Aaron", "country": {"$eq": "NZ"}}] + } + """, + SortDefinition.class, + """ + { + "type": ["object", "null"], + "description": "A Data API sort object mapping paths to 1 (ascending) or -1 (descending). Collections also accept a single $vector, $vectorize, or $lexical sort. Tables use the vector or lexical column name.", + "additionalProperties": true, + "examples": [{"user.age": -1, "user.name": 1}, {"$vector": [0.1, 0.2, 0.3]}, {"$vectorize": "search text"}] + } + """, + FindAndRerankSort.class, + """ + { + "type": ["object", "null"], + "description": "Use $hybrid with a query string or an object containing $vectorize or $vector, and optionally $lexical. $vector and $vectorize cannot be used together. Query text and required collection capabilities are checked when resolving the command.", + "properties": { + "$hybrid": { + "anyOf": [ + {"type": "string"}, + { + "type": "object", + "properties": { + "$vectorize": {"type": ["string", "null"]}, + "$lexical": {"type": ["string", "null"]}, + "$vector": { + "anyOf": [ + {"type": "array", "items": {"type": "number"}}, + {"type": "object", "properties": {"$binary": {"type": "string"}}, "required": ["$binary"], "additionalProperties": false}, + {"type": "null"} + ] + } + }, + "additionalProperties": false, + "not": {"required": ["$vector", "$vectorize"]} + } + ] + } + }, + "additionalProperties": false, + "examples": [ + {"$hybrid": "search text"}, + {"$hybrid": {"$vectorize": "vector query", "$lexical": "lexical query"}}, + {"$hybrid": {"$vector": [0.1, 0.2, 0.3], "$lexical": "lexical query"}}, + {"$hybrid": {"$vector": {"$binary": "P4AAAEAAAABAQAAA"}, "$lexical": "lexical query"}} + ] + } + """, + FindAndRerankCommand.HybridLimits.class, + """ + { + "description": "Candidate read limits as one integer for both legs, or an object containing both $vector and $lexical integer limits. Accepted bounds are determined by server configuration.", + "anyOf": [ + {"type": "integer"}, + { + "type": "object", + "properties": {"$vector": {"type": "integer"}, "$lexical": {"type": "integer"}}, + "required": ["$vector", "$lexical"], + "additionalProperties": false + }, + {"type": "null"} + ], + "examples": [50, {"$vector": 50, "$lexical": 10}] + } + """); + + // This override is field-specific: collection creation uses the same service record but may + // advertise authentication and parameters separately from the findAndRerank request override. + private static final String RERANK_OVERRIDE_SCHEMA = + """ + { + "type": ["object", "null"], + "description": "Optional reranking service override. Supply both provider and modelName.", + "properties": {"provider": {"type": "string"}, "modelName": {"type": "string"}}, + "required": ["provider", "modelName"], + "additionalProperties": false, + "examples": [{"provider": "nvidia", "modelName": "nvidia/llama-3.2-nv-rerankqa-1b-v2"}] + } + """; + + @Override + public void customize(SchemaGeneratorConfigBuilder builder) { + builder + .forFields() + .withIgnoreCheck(field -> field.getType().getErasedType() == CommandFeatures.class) + .withCustomDefinitionProvider( + (field, context) -> { + if (field.getDeclaringType().getErasedType() == FindAndRerankCommand.Options.class + && field.getDeclaredName().equals("rerankServiceOverride")) { + return new CustomPropertyDefinition( + parseSchema(RERANK_OVERRIDE_SCHEMA, context), + CustomDefinition.AttributeInclusion.NO); + } + return null; + }) + .withPropertyNameOverrideResolver( + field -> { + var property = field.getAnnotationConsideringFieldAndGetter(JsonProperty.class); + return property != null && !property.value().isEmpty() ? property.value() : null; + }); + + builder + .forTypesInGeneral() + .withCustomDefinitionProvider( + (javaType, context) -> { + var schemaJson = CLAUSE_SCHEMAS.get(javaType.getErasedType()); + if (schemaJson == null) { + return null; + } + return new CustomDefinition( + parseSchema(schemaJson, context), + CustomDefinition.DefinitionType.INLINE, + CustomDefinition.AttributeInclusion.NO); + }); + } + + private static ObjectNode parseSchema(String schemaJson, SchemaGenerationContext context) { + try { + return (ObjectNode) context.getGeneratorConfig().getObjectMapper().readTree(schemaJson); + } catch (JsonProcessingException e) { + throw new IllegalStateException("Invalid MCP clause schema", e); + } + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index b55aed0286..46e6ba3f3f 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -6,13 +6,17 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.victools.jsonschema.generator.*; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; import io.quarkiverse.mcp.server.GlobalInputSchemaGenerator; import io.quarkiverse.mcp.server.ToolManager; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; +import io.stargate.sgv2.jsonapi.TestConstants; import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.FilterDefinition; import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.SortDefinition; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; +import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateCollectionCommand; import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; @@ -21,6 +25,7 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; /** Contracts between advertised MCP schemas and the actual Data API JSON deserializers. */ @@ -54,7 +59,14 @@ void everyAdvertisedClauseExampleDeserializes(Class clauseType) throws Except assertNotNull(examples, "Clause schema must advertise examples of the accepted JSON"); assertFalse(examples.isEmpty()); for (var example : examples) { - assertNotNull(objectMapper.treeToValue(example, clauseType)); + assertSchemaAccepts(schema, example); + var clause = objectMapper.treeToValue(example, clauseType); + assertNotNull(clause); + if (clause instanceof FilterDefinition filter) { + assertNotNull(filter.build(new TestConstants().collectionContext())); + } else if (clause instanceof SortDefinition sort) { + assertNotNull(sort.build(new TestConstants().collectionContext())); + } } } @@ -74,14 +86,24 @@ void rerankToolAdvertisesWireFormatAndOverrideName() throws Exception { var options = properties.path("options").path("properties"); assertTrue(options.has("rerank")); assertFalse(options.has("rerankServiceOverride")); - assertTrue(options.path("hybridLimits").has("anyOf")); + var hybridLimits = options.path("hybridLimits"); + assertSchemaAccepts(hybridLimits, objectMapper.readTree("50")); + assertSchemaAccepts(hybridLimits, objectMapper.readTree("{\"$vector\": 50, \"$lexical\": 10}")); + var rerankProperties = options.path("rerank").path("properties"); + assertTrue(rerankProperties.has("provider")); + assertTrue(rerankProperties.has("modelName")); + assertFalse(rerankProperties.has("authentication")); + assertFalse(rerankProperties.has("parameters")); assertFalse(schema.toString().contains("commandFeatures")); assertFalse(schema.toString().contains("vectorLimit")); for (var example : properties.path("sort").path("examples")) { assertNotNull(objectMapper.treeToValue(example, FindAndRerankSort.class)); } - for (var example : options.path("hybridLimits").path("examples")) { + var limitExamples = hybridLimits.findValues("examples"); + assertFalse(limitExamples.isEmpty()); + for (var example : limitExamples.getFirst()) { + assertSchemaAccepts(hybridLimits, example); assertNotNull(objectMapper.treeToValue(example, FindAndRerankCommand.HybridLimits.class)); } } @@ -110,6 +132,54 @@ void openApiSortExamplesAreValidClauseJson() throws Exception { } } + @ParameterizedTest + @MethodSource("invalidWireShapes") + void schemaRejectsInvalidWireShapes(Class clauseType, String json) throws Exception { + var schema = + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(schemaFor(clauseType)); + assertFalse(schema.validate(objectMapper.readTree(json)).isEmpty()); + } + + static Stream invalidWireShapes() { + return Stream.of( + Arguments.of(FindAndRerankSort.class, "{\"vectorizeSort\": \"query\"}"), + Arguments.of(FindAndRerankSort.class, "{\"$hybrid\": {\"$lexical\": 1}}"), + Arguments.of(FindAndRerankSort.class, "{\"$hybrid\": {\"$vector\": \"query\"}}"), + Arguments.of( + FindAndRerankSort.class, "{\"$hybrid\": {\"$vector\": null, \"$vectorize\": null}}"), + Arguments.of( + FindAndRerankCommand.HybridLimits.class, "{\"vectorLimit\": 50, \"lexicalLimit\": 10}"), + Arguments.of(FindAndRerankCommand.HybridLimits.class, "{\"$vector\": 50}"), + Arguments.of( + FindAndRerankCommand.HybridLimits.class, "{\"$vector\": 50, \"$lexical\": \"10\"}")); + } + + @Test + void openApiLimitExamplesAreValidClauseJson() throws Exception { + var schema = + FindAndRerankCommand.Options.class + .getDeclaredField("hybridLimits") + .getAnnotation(Schema.class); + for (var example : schema.examples()) { + assertNotNull(objectMapper.readValue(example, FindAndRerankCommand.HybridLimits.class)); + } + } + + private void assertSchemaAccepts(JsonNode schema, JsonNode example) { + var validator = + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(schema); + assertTrue(validator.validate(example).isEmpty(), () -> validator.validate(example).toString()); + } + + @Test + void collectionCreationKeepsItsOwnRerankServiceFields() { + var properties = + schemaFor(CreateCollectionCommand.Options.RerankServiceDesc.class).path("properties"); + assertTrue(properties.has("authentication")); + assertTrue(properties.has("parameters")); + } + private JsonNode inputSchema(String name) throws Exception { var tool = toolManager.getTool(name); assertNotNull(tool); @@ -122,6 +192,7 @@ private JsonNode schemaFor(Class type) { objectMapper, SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) .without(Option.SCHEMA_VERSION_INDICATOR); new McpSchemaDescriptionCustomizer().customize(builder); + new McpClauseSchemaCustomizer().customize(builder); return new SchemaGenerator(builder.build()).generateSchema(type); } } From acfbe5db60f962e2a090d851dd549b0bdd724f28 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 6 Oct 2026 15:10:12 -0700 Subject: [PATCH 03/35] test: validate request rerank override schema examples Align the HTTP and MCP hybrid descriptions. All 124 targeted tests, formatting, and the packaged build pass on Java 21. --- .../model/command/impl/FindAndRerankCommand.java | 3 +-- .../api/v1/mcp/McpClauseSchemaContractTest.java | 13 ++++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) 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 38ba5fc946..589f8c2ce6 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 @@ -30,8 +30,7 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; @Schema( - description = - "Finds documents using using vector and lexical sorting, then reranks the results.") + description = "Finds candidate documents using a $hybrid sort, then reranks their passages.") @JsonTypeName(CommandName.Names.FIND_AND_RERANK) public record FindAndRerankCommand( @Valid @JsonProperty("filter") FilterDefinition filterDefinition, diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 46e6ba3f3f..9f40a746ba 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -97,9 +97,20 @@ void rerankToolAdvertisesWireFormatAndOverrideName() throws Exception { assertFalse(schema.toString().contains("commandFeatures")); assertFalse(schema.toString().contains("vectorLimit")); - for (var example : properties.path("sort").path("examples")) { + var sortSchema = properties.path("sort"); + assertFalse(sortSchema.path("examples").isEmpty()); + for (var example : sortSchema.path("examples")) { + assertSchemaAccepts(sortSchema, example); assertNotNull(objectMapper.treeToValue(example, FindAndRerankSort.class)); } + var rerankSchema = options.path("rerank"); + assertFalse(rerankSchema.path("examples").isEmpty()); + for (var example : rerankSchema.path("examples")) { + assertSchemaAccepts(rerankSchema, example); + assertNotNull( + objectMapper.treeToValue( + example, CreateCollectionCommand.Options.RerankServiceDesc.class)); + } var limitExamples = hybridLimits.findValues("examples"); assertFalse(limitExamples.isEmpty()); for (var example : limitExamples.getFirst()) { From 027c386d9c6cd809bfbc6a2a659b4a3cc3f981f1 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 12 Oct 2026 16:39:50 -0700 Subject: [PATCH 04/35] test: reproduce rejection of stateless MCP 2026-07-28 requests RED: McpProtocolTest executes against MCP 1.13.0 and fails because the first tools/list POST returns HTTP 400 instead of 200. --- .../jsonapi/api/v1/mcp/McpProtocolTest.java | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java new file mode 100644 index 0000000000..44b23f1690 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java @@ -0,0 +1,59 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Exercises the HTTP wire contract without a client that might initialize a session for us. */ +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +class McpProtocolTest { + private static final String PROTOCOL_VERSION = "2026-07-28"; + + @Test + void listsToolsWithoutInitializationOrSession() { + // Each POST is independent. clientInfo, discovery and initialization are deliberately absent. + for (int id = 1; id <= 2; id++) { + Response response = request("tools/list", PROTOCOL_VERSION, id); + assertThat(response.statusCode()).as(response.asString()).isEqualTo(200); + assertThat(response.header("Mcp-Session-Id")).isNull(); + assertThat(response.jsonPath().getInt("id")).isEqualTo(id); + assertThat(response.jsonPath().getList("result.tools.name")).isNotEmpty(); + assertThat(response.jsonPath().getString("result.resultType")).isEqualTo("complete"); + } + } + + private Response request(String method, String version, int id) { + return given() + .contentType(ContentType.JSON) + .accept("application/json, text/event-stream") + // Existing header identity fixture only. OAuth admission is tracked separately in #2642. + .header("Token", "protocol-test-credential") + .header("MCP-Protocol-Version", version) + .header("Mcp-Method", method) + .body( + Map.of( + "jsonrpc", + "2.0", + "id", + id, + "method", + method, + "params", + Map.of( + "_meta", + Map.of( + "io.modelcontextprotocol/protocolVersion", + version, + "io.modelcontextprotocol/clientCapabilities", + Map.of())))) + .post("/v1/mcp"); + } +} From b2088a9bd442510a95b549ad335176547435e320 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 12 Oct 2026 16:40:59 -0700 Subject: [PATCH 05/35] fix: upgrade MCP runtime for stateless protocol requests GREEN: the same raw HTTP McpProtocolTest passes with MCP BOM 2.0.3 on Quarkus 3.38.1 and JDK 21. Both independent tools/list requests return complete results without Mcp-Session-Id. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index f8093ca942..9ab11340f1 100644 --- a/pom.xml +++ b/pom.xml @@ -86,7 +86,7 @@ io.quarkiverse.mcp quarkus-mcp-server-bom - 1.13.0 + 2.0.3 pom import From bdc563c29305de74340e23eecc6a85c680325ab1 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 12 Oct 2026 17:14:41 -0700 Subject: [PATCH 06/35] test: expect framework server metadata in MCP responses Both DSE and HCD CI exposed four obsolete empty-metadata assertions after the framework upgrade. Require only serverInfo and validate its name and version. The two affected DSE integration classes pass locally: 17 tests, zero failures. --- .../api/v1/mcp/McpIntegrationTestBase.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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 392bf69c8f..e2f7addc46 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 @@ -176,16 +176,23 @@ protected Consumer assertStatusOnlyWithJson(Consumer s }; } + private static void assertServerInfoOnly(ToolResponse response) { + assertThat(response._meta()).containsOnlyKeys(MetaKey.SERVER_INFO); + var serverInfo = (JsonObject) response._meta().get(MetaKey.SERVER_INFO); + assertThat(serverInfo.getString("name")).isEqualTo("Stargate DATA API"); + assertThat(serverInfo.getString("version")).isNotBlank(); + } + /** - * Assert error response structure is valid (no meta_ and content), then apply additional - * assertions on the error JsonArray extracted from structuredContent. + * Assert error response structure is valid (server metadata and no text content), then apply + * additional assertions on 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(); - assertThat(response._meta()).isEmpty(); + assertServerInfoOnly(response); assertTrue(response.isError()); assertThat(response.structuredContent()).isNotNull(); @@ -198,8 +205,8 @@ protected Consumer assertErrorOnly(Consumer errorsAsser } /** - * Assert data only response is valid (no meta_, content and error), then apply additional - * assertions on the structuredContent holds the data. + * Assert data only response is valid (server metadata, no text content or error), then apply + * additional assertions on the structuredContent holds the data. * * @param dataAssertions additional assertions to run on the data JsonObject */ @@ -207,7 +214,7 @@ protected Consumer assertDataOnly(Consumer dataAsserti return response -> { assertFalse(response.isError()); assertThat(response.content()).isEmpty(); - assertThat(response._meta()).isEmpty(); + assertServerInfoOnly(response); assertNotNull(response.structuredContent()); var data = (JsonObject) response.structuredContent(); From bbacbdac4fc120a02f1612b641deaed2829744c2 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 15:47:48 -0700 Subject: [PATCH 07/35] Use text blocks for rerank sort examples --- .../model/command/clause/sort/FindAndRerankSort.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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 4cccbea88a..e1f7f5bd6e 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 @@ -22,9 +22,15 @@ type = SchemaType.OBJECT, implementation = Map.class, 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": "Same query for vectorize and BM25 sorting"} + """, + """ + {"$hybrid": {"$vectorize": "vectorize sort query", "$lexical": "lexical sort"}} + """, + """ + {"$hybrid": {"$vector": [1, 2, 3], "$lexical": "lexical sort"}} + """ }) public record FindAndRerankSort( String vectorizeSort, String lexicalSort, float[] vectorSort, CommandFeatures commandFeatures) From 2f2720eff990d49c3075929f8b5b332dd1397479 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 15:50:21 -0700 Subject: [PATCH 08/35] Use a text block for the hybrid limits example --- .../api/model/command/impl/FindAndRerankCommand.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 589f8c2ce6..df8f149f68 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 @@ -72,7 +72,12 @@ public record Options( @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 = {"50", "{\"$vector\": 50, \"$lexical\": 10}"}) + examples = { + "50", + """ + {"$vector": 50, "$lexical": 10} + """ + }) HybridLimits hybridLimits, /** ---- */ @Schema( From 773477d57f3eba6987d5982bcac469ce9c34bc8f Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 15:52:43 -0700 Subject: [PATCH 09/35] Restore findAndRerank descriptions and keep typo fixes --- .../jsonapi/api/model/command/impl/FindAndRerankCommand.java | 3 +-- .../sgv2/jsonapi/api/v1/mcp/CollectionCommandTools.java | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) 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 df8f149f68..40037f4524 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 @@ -29,8 +29,7 @@ import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.media.Schema; -@Schema( - description = "Finds candidate documents using a $hybrid sort, then reranks their passages.") +@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, 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 b2f5dd21f4..e7dba20b03 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,8 +148,7 @@ public Uni estimatedDocumentCount( return mcpResource.processCollectionCommand(keyspace, collection, command); } - @Tool( - description = "Finds candidate documents using a $hybrid sort, then reranks their passages.") + @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, From fdb5f1a36e4d66694bb75c6257e103ab32b193ef Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 15:58:33 -0700 Subject: [PATCH 10/35] Read the MCP rerank sort description from its class --- .../clause/sort/FindAndRerankSort.java | 4 +++ .../api/v1/mcp/CollectionCommandTools.java | 6 +--- .../api/v1/mcp/McpClauseSchemaCustomizer.java | 3 +- .../mcp/McpSchemaDescriptionCustomizer.java | 20 +++++++++---- .../v1/mcp/McpClauseSchemaContractTest.java | 9 ++++++ .../McpSchemaDescriptionCustomizerTest.java | 28 +++++++++++++++++++ 6 files changed, 58 insertions(+), 12 deletions(-) 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 e1f7f5bd6e..bb55f95cb9 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 @@ -19,6 +19,10 @@ */ @JsonDeserialize(using = FindAndRerankSortClauseDeserializer.class) @Schema( + description = + "Use $hybrid with a query string or an object containing $vectorize or $vector, and optionally $lexical." + + " $vector and $vectorize cannot be used together." + + " Query text and required collection capabilities are checked when resolving the command.", type = SchemaType.OBJECT, implementation = Map.class, examples = { 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 e7dba20b03..75863e5a38 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 @@ -154,11 +154,7 @@ public Uni findAndRerank( @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 = - "A $hybrid sort: query text, or an object using $vector or $vectorize and optionally $lexical.", - required = false) - FindAndRerankSort sort, + @ToolArg(required = false) FindAndRerankSort sort, @ToolArg( description = "Reranking options: limit, hybridLimits, rerankQuery, rerankOn, includeScores, includeSortVector, and an optional rerank service override.", diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java index 91427e51df..761235dd89 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java @@ -46,7 +46,6 @@ public class McpClauseSchemaCustomizer implements SchemaGeneratorConfigCustomize """ { "type": ["object", "null"], - "description": "Use $hybrid with a query string or an object containing $vectorize or $vector, and optionally $lexical. $vector and $vectorize cannot be used together. Query text and required collection capabilities are checked when resolving the command.", "properties": { "$hybrid": { "anyOf": [ @@ -143,7 +142,7 @@ public void customize(SchemaGeneratorConfigBuilder builder) { return new CustomDefinition( parseSchema(schemaJson, context), CustomDefinition.DefinitionType.INLINE, - CustomDefinition.AttributeInclusion.NO); + CustomDefinition.AttributeInclusion.YES); }); } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizer.java index 6e5e66736d..010216df29 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizer.java @@ -7,19 +7,29 @@ /** * Enables MicroProfile {@link Schema#description()} resolution for the victools JSON schema - * generator used by Quarkus MCP Server. Without this customizer, nested record fields annotated - * with {@code @Schema(description = "...")} will not have their descriptions included in the MCP - * tool schema output. + * generator used by Quarkus MCP Server. Without this customizer, classes and record fields + * annotated with {@code @Schema(description = "...")} will not have their descriptions included in + * the MCP tool schema output. * *

The Quarkus MCP Server's {@code DefaultSchemaGenerator} uses {@code OptionPreset.PLAIN_JSON}, - * which does not read any annotations by default. This customizer bridges that gap by adding a - * field-level description resolver for MicroProfile OpenAPI's {@code @Schema} annotation. + * which does not read any annotations by default. This customizer bridges that gap by adding field + * and type description resolvers for MicroProfile OpenAPI's {@code @Schema} annotation. */ @Singleton public class McpSchemaDescriptionCustomizer implements SchemaGeneratorConfigCustomizer { @Override public void customize(SchemaGeneratorConfigBuilder builder) { + builder + .forTypesInGeneral() + .withDescriptionResolver( + type -> { + Schema schemaAnn = type.getType().getErasedType().getAnnotation(Schema.class); + return schemaAnn != null && !schemaAnn.description().isEmpty() + ? schemaAnn.description() + : null; + }); + builder .forFields() .withDescriptionResolver( diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 9f40a746ba..06bfcc0eeb 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -78,6 +78,15 @@ static Stream> clauseTypes() { FindAndRerankCommand.HybridLimits.class); } + @Test + void rerankToolUsesSortClassDescription() throws Exception { + var description = FindAndRerankSort.class.getAnnotation(Schema.class).description(); + assertFalse(description.isBlank()); + assertEquals( + description, + inputSchema("findAndRerank").path("properties").path("sort").path("description").asText()); + } + @Test void rerankToolAdvertisesWireFormatAndOverrideName() throws Exception { var schema = inputSchema("findAndRerank"); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizerTest.java index 8ec17c71c1..d7d905002f 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizerTest.java @@ -37,6 +37,34 @@ record TestCreateTable( @Schema(description = "Configuration options") TestOptions options, Boolean noAnnotation) {} + @Schema(description = "Reusable value description") + record TestDescribedType(String value) {} + + record TestTypeDescription(TestDescribedType value) {} + + @Test + void shouldIncludeRootTypeDescription() { + var configBuilder = baseConfigBuilder(); + new McpSchemaDescriptionCustomizer().customize(configBuilder); + + var schema = new SchemaGenerator(configBuilder.build()).generateSchema(TestDescribedType.class); + + assertEquals("Reusable value description", schema.path("description").asText()); + } + + @Test + void shouldIncludeNestedTypeDescriptionWithoutFieldDescription() { + var configBuilder = baseConfigBuilder(); + new McpSchemaDescriptionCustomizer().customize(configBuilder); + + var schema = + new SchemaGenerator(configBuilder.build()).generateSchema(TestTypeDescription.class); + + assertEquals( + "Reusable value description", + schema.path("properties").path("value").path("description").asText()); + } + @Test void shouldIncludeSchemaDescriptionWithCustomizer() { var configBuilder = baseConfigBuilder(); From de4573f08f27f9168f9e5d3deae027d7ca394633 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 16:00:24 -0700 Subject: [PATCH 11/35] Remove the duplicate reranking option list --- .../sgv2/jsonapi/api/v1/mcp/CollectionCommandTools.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 75863e5a38..0ca9bf97dd 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 @@ -155,10 +155,7 @@ public Uni findAndRerank( @ToolArg(description = "filter", required = false) FilterDefinition filter, @ToolArg(description = "projection", required = false) JsonNode projection, @ToolArg(required = false) FindAndRerankSort sort, - @ToolArg( - description = - "Reranking options: limit, hybridLimits, rerankQuery, rerankOn, includeScores, includeSortVector, and an optional rerank service override.", - required = false) + @ToolArg(description = "Reranking options", required = false) FindAndRerankCommand.Options options) { var command = new FindAndRerankCommand(filter, projection, sort, options); From c61a5362f6077277be8f12c2a574f17df40fcffd Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 16:02:24 -0700 Subject: [PATCH 12/35] Clarify the clause schema customizer documentation --- .../sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java index 761235dd89..40037ad0d8 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java @@ -17,8 +17,8 @@ import java.util.Map; /** - * Advertises the Data API wire format for clauses whose JSON shape differs from their Java fields. - * Custom deserializers and delegating creators cannot be described by reflecting their fields. + * Generates schemas for Data API clauses whose JSON shape differs from their Java fields. Custom + * deserializers and delegating creators cannot be described by reflecting their fields. */ @Singleton public class McpClauseSchemaCustomizer implements SchemaGeneratorConfigCustomizer { From f665be83e1571d969e8f00a7e1e59ead4df0a1ea Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 16:18:09 -0700 Subject: [PATCH 13/35] Share typed clause schemas between OpenAPI and MCP --- .../clause/filter/FilterDefinition.java | 29 +++- .../command/clause/filter/SortDefinition.java | 31 +++- .../clause/sort/FindAndRerankSort.java | 74 ++++++++- .../command/impl/FindAndRerankCommand.java | 58 ++++++- .../api/model/schema/ApiSchemaFilter.java | 76 +++++++++ .../api/model/schema/ApiSchemaProvider.java | 36 +++++ .../api/v1/mcp/McpClauseSchemaCustomizer.java | 148 ++++-------------- .../v1/mcp/McpClauseSchemaContractTest.java | 90 +++++++++++ 8 files changed, 418 insertions(+), 124 deletions(-) create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaFilter.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java 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..a456fc45f7 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,12 +1,17 @@ package io.stargate.sgv2.jsonapi.api.model.command.clause.filter; +import static io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider.type; +import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; + import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.databind.JsonNode; +import io.quarkus.runtime.annotations.RegisterForReflection; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.JsonDefinition; import io.stargate.sgv2.jsonapi.api.model.command.builders.FilterClauseBuilder; +import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.config.OperationsConfig; -import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import java.util.Map; import org.eclipse.microprofile.openapi.annotations.media.Schema; /** @@ -14,13 +19,31 @@ * allows for lazy deserialization into a {@link FilterClause}. */ @Schema( - type = SchemaType.OBJECT, - implementation = FilterClause.class, + description = + "A Data API filter object mapping document paths to values or operator expressions, such as $eq, $gt, $in, $and, and $or.", + implementation = FilterDefinition.SchemaProvider.class, + ref = FilterDefinition.SchemaProvider.REF, examples = """ {"name": "Aaron", "country": {"$eq": "NZ"}, "age": {"$gt": 40}} """) public class FilterDefinition extends JsonDefinition { + @Schema(name = SchemaProvider.NAME) + @RegisterForReflection + public static class SchemaProvider implements ApiSchemaProvider { + public static final String NAME = "FilterDefinition"; + public static final String REF = "#/components/schemas/" + NAME; + + @Override + public org.eclipse.microprofile.openapi.models.media.Schema schema() { + return type(OBJECT, NULL) + .description(FilterDefinition.class.getAnnotation(Schema.class).description()) + .additionalPropertiesBoolean(true) + .addExample(Map.of("age", Map.of("$gt", 40))) + .addExample(Map.of("name", "Aaron", "country", Map.of("$eq", "NZ"))); + } + } + /** * Lazily deserialized {@link FilterClause} from the JSON value. We need this due to existing * reliance on specific stateful instances of {@link FilterClause}. 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..0bde1bf082 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,15 +1,21 @@ package io.stargate.sgv2.jsonapi.api.model.command.clause.filter; +import static io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider.type; +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; +import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; + import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import io.quarkus.runtime.annotations.RegisterForReflection; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.JsonDefinition; import io.stargate.sgv2.jsonapi.api.model.command.builders.SortClauseBuilder; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortClause; +import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import java.util.List; -import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; +import java.util.Map; import org.eclipse.microprofile.openapi.annotations.media.Schema; /** @@ -17,13 +23,32 @@ * for lazy deserialization into a {@link SortClause}. */ @Schema( - type = SchemaType.OBJECT, - implementation = SortClause.class, + description = + "A Data API sort object mapping paths to 1 (ascending) or -1 (descending). Collections also accept a single $vector, $vectorize, or $lexical sort. Tables use the vector or lexical column name.", + implementation = SortDefinition.SchemaProvider.class, + ref = SortDefinition.SchemaProvider.REF, examples = """ {"user.age" : -1, "user.name" : 1} """) public class SortDefinition extends JsonDefinition { + @Schema(name = SchemaProvider.NAME) + @RegisterForReflection + public static class SchemaProvider implements ApiSchemaProvider { + public static final String NAME = "SortDefinition"; + public static final String REF = "#/components/schemas/" + NAME; + + @Override + public org.eclipse.microprofile.openapi.models.media.Schema schema() { + return type(OBJECT, NULL) + .description(SortDefinition.class.getAnnotation(Schema.class).description()) + .additionalPropertiesBoolean(true) + .addExample(Map.of("user.age", -1, "user.name", 1)) + .addExample(Map.of(VECTOR_EMBEDDING_FIELD, List.of(0.1, 0.2, 0.3))) + .addExample(Map.of(VECTOR_EMBEDDING_TEXT_FIELD, "search text")); + } + } + /** * Lazily deserialized {@link SortClause} from the JSON value. We need this due to existing * reliance on specific stateful instances of {@link SortClause}. 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 bb55f95cb9..266d9ff0c1 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,10 +1,16 @@ package io.stargate.sgv2.jsonapi.api.model.command.clause.sort; +import static io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider.type; +import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; +import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; + import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import io.quarkus.runtime.annotations.RegisterForReflection; +import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; 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.OASFactory; import org.eclipse.microprofile.openapi.annotations.media.Schema; /** @@ -23,8 +29,8 @@ "Use $hybrid with a query string or an object containing $vectorize or $vector, and optionally $lexical." + " $vector and $vectorize cannot be used together." + " Query text and required collection capabilities are checked when resolving the command.", - type = SchemaType.OBJECT, - implementation = Map.class, + implementation = FindAndRerankSort.SchemaProvider.class, + ref = FindAndRerankSort.SchemaProvider.REF, examples = { """ {"$hybrid": "Same query for vectorize and BM25 sorting"} @@ -40,6 +46,68 @@ public record FindAndRerankSort( String vectorizeSort, String lexicalSort, float[] vectorSort, CommandFeatures commandFeatures) implements Recordable { + @Schema(name = SchemaProvider.NAME) + @RegisterForReflection + public static class SchemaProvider implements ApiSchemaProvider { + public static final String NAME = "FindAndRerankSort"; + public static final String REF = "#/components/schemas/" + NAME; + + @Override + public org.eclipse.microprofile.openapi.models.media.Schema schema() { + var vector = + OASFactory.createSchema() + .addAnyOf(type(ARRAY).items(type(NUMBER))) + .addAnyOf( + type(OBJECT) + .addProperty(BINARY_VECTOR_TEXT_FIELD, type(STRING)) + .addRequired(BINARY_VECTOR_TEXT_FIELD) + .additionalPropertiesBoolean(false)) + .addAnyOf(type(NULL)); + var hybrid = + OASFactory.createSchema() + .addAnyOf(type(STRING)) + .addAnyOf( + type(OBJECT) + .addProperty(VECTOR_EMBEDDING_TEXT_FIELD, type(STRING, NULL)) + .addProperty(LEXICAL_CONTENT_FIELD, type(STRING, NULL)) + .addProperty(VECTOR_EMBEDDING_FIELD, vector) + .additionalPropertiesBoolean(false) + .not( + OASFactory.createSchema() + .required( + List.of(VECTOR_EMBEDDING_FIELD, VECTOR_EMBEDDING_TEXT_FIELD)))); + return type(OBJECT, NULL) + .description(FindAndRerankSort.class.getAnnotation(Schema.class).description()) + .addProperty(HYBRID_FIELD, hybrid) + .additionalPropertiesBoolean(false) + .addExample(Map.of(HYBRID_FIELD, "search text")) + .addExample( + Map.of( + HYBRID_FIELD, + Map.of( + VECTOR_EMBEDDING_TEXT_FIELD, + "vector query", + LEXICAL_CONTENT_FIELD, + "lexical query"))) + .addExample( + Map.of( + HYBRID_FIELD, + Map.of( + VECTOR_EMBEDDING_FIELD, + List.of(0.1, 0.2, 0.3), + LEXICAL_CONTENT_FIELD, + "lexical query"))) + .addExample( + Map.of( + HYBRID_FIELD, + Map.of( + VECTOR_EMBEDDING_FIELD, + Map.of(BINARY_VECTOR_TEXT_FIELD, "P4AAAEAAAABAQAAA"), + LEXICAL_CONTENT_FIELD, + "lexical query"))); + } + } + 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 40037f4524..940f4832b2 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,6 +1,8 @@ package io.stargate.sgv2.jsonapi.api.model.command.impl; +import static io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider.type; import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; +import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonTypeName; @@ -12,9 +14,11 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.node.NumericNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.quarkus.runtime.annotations.RegisterForReflection; import io.stargate.sgv2.jsonapi.api.model.command.*; import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.FilterDefinition; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; +import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.config.constants.RerankingConstants; import io.stargate.sgv2.jsonapi.metrics.CommandFeature; @@ -25,7 +29,9 @@ import jakarta.validation.constraints.Positive; import java.io.IOException; import java.util.List; +import java.util.Map; import javax.annotation.Nullable; +import org.eclipse.microprofile.openapi.OASFactory; import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.media.Schema; @@ -71,6 +77,7 @@ public record Options( @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.", + ref = HybridLimits.SchemaProvider.REF, examples = { "50", """ @@ -116,11 +123,37 @@ 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) {} + // Collection creation uses the same service record but also accepts authentication and + // parameters. + @Schema(name = RerankOverrideSchema.NAME) + @RegisterForReflection + public static class RerankOverrideSchema implements ApiSchemaProvider { + public static final String NAME = "RerankServiceOverride"; + public static final String REF = "#/components/schemas/" + NAME; + + @Override + public org.eclipse.microprofile.openapi.models.media.Schema schema() { + return type(OBJECT, NULL) + .addProperty("provider", type(STRING)) + .addProperty("modelName", type(STRING)) + .required(List.of("provider", "modelName")) + .additionalPropertiesBoolean(false) + .addExample( + Map.of("provider", "nvidia", "modelName", "nvidia/llama-3.2-nv-rerankqa-1b-v2")); + } + } + @JsonDeserialize(using = HybridLimitsDeserializer.class) + @Schema( + description = + "Candidate read limits as one integer for both legs, or an object containing both $vector and $lexical integer limits. Accepted bounds are determined by server configuration.", + implementation = HybridLimits.SchemaProvider.class, + ref = HybridLimits.SchemaProvider.REF) public record HybridLimits( @JsonProperty(DocumentConstants.Fields.VECTOR_EMBEDDING_FIELD) int vectorLimit, /** ---- */ @@ -128,6 +161,29 @@ public record HybridLimits( CommandFeatures commandFeatures) implements Recordable { + @Schema(name = SchemaProvider.NAME) + @RegisterForReflection + public static class SchemaProvider implements ApiSchemaProvider { + public static final String NAME = "HybridLimits"; + public static final String REF = "#/components/schemas/" + NAME; + + @Override + public org.eclipse.microprofile.openapi.models.media.Schema schema() { + return OASFactory.createSchema() + .description(HybridLimits.class.getAnnotation(Schema.class).description()) + .addAnyOf(type(INTEGER)) + .addAnyOf( + type(OBJECT) + .addProperty(VECTOR_EMBEDDING_FIELD, type(INTEGER)) + .addProperty(LEXICAL_CONTENT_FIELD, type(INTEGER)) + .required(List.of(VECTOR_EMBEDDING_FIELD, LEXICAL_CONTENT_FIELD)) + .additionalPropertiesBoolean(false)) + .addAnyOf(type(NULL)) + .addExample(50) + .addExample(Map.of(VECTOR_EMBEDDING_FIELD, 50, LEXICAL_CONTENT_FIELD, 10)); + } + } + public static final HybridLimits DEFAULT = new HybridLimits( RerankingConstants.HybridSearchLimits.DEFAULT, diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaFilter.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaFilter.java new file mode 100644 index 0000000000..7789269663 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaFilter.java @@ -0,0 +1,76 @@ +package io.stargate.sgv2.jsonapi.api.model.schema; + +import io.quarkus.smallrye.openapi.OpenApiFilter; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.eclipse.microprofile.openapi.OASFactory; +import org.eclipse.microprofile.openapi.OASFilter; +import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.eclipse.microprofile.openapi.models.OpenAPI; +import org.jboss.jandex.IndexView; + +/** Installs the clause schemas at build time, when Quarkus provides the application class index. */ +@OpenApiFilter(stages = OpenApiFilter.RunStage.BUILD) +public class ApiSchemaFilter implements OASFilter { + private final List> providers; + private final Set references; + + public ApiSchemaFilter(IndexView index) { + providers = + index.getAllKnownImplementations(ApiSchemaProvider.class).stream() + .>map( + info -> { + try { + return Class.forName( + info.name().toString(), + true, + Thread.currentThread().getContextClassLoader()) + .asSubclass(ApiSchemaProvider.class); + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + "Cannot load API schema provider " + info.name(), e); + } + }) + .toList(); + references = + providers.stream() + .map(provider -> "#/components/schemas/" + schemaName(provider)) + .collect(Collectors.toSet()); + if (references.size() != providers.size()) { + throw new IllegalStateException("API schema providers must have unique schema names"); + } + } + + @Override + public org.eclipse.microprofile.openapi.models.media.Schema filterSchema( + org.eclipse.microprofile.openapi.models.media.Schema schema) { + if (references.contains(schema.getRef())) { + // The scanner infers an object from the Java class even when its wire schema allows other + // types. + schema.setType(null); + } + return schema; + } + + @Override + public void filterOpenAPI(OpenAPI openAPI) { + if (openAPI.getComponents() == null) { + openAPI.setComponents(OASFactory.createComponents()); + } + for (var provider : providers) { + openAPI + .getComponents() + .addSchema(schemaName(provider), ApiSchemaProvider.create(provider).schema()); + } + } + + private static String schemaName(Class provider) { + var annotation = provider.getAnnotation(Schema.class); + if (annotation == null || annotation.name().isBlank()) { + throw new IllegalStateException( + "API schema provider needs a schema name " + provider.getName()); + } + return annotation.name(); + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java new file mode 100644 index 0000000000..bfa1273370 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java @@ -0,0 +1,36 @@ +package io.stargate.sgv2.jsonapi.api.model.schema; + +import java.util.List; +import org.eclipse.microprofile.openapi.OASFactory; +import org.eclipse.microprofile.openapi.models.media.Schema; + +/** + * A wire schema shared by OpenAPI and MCP. Link a provider from the owning type or field with + * {@code @Schema(implementation = ..., ref = ...)} and give the provider a unique + * {@code @Schema(name = ...)}. The reference prevents OpenAPI from also scanning the internal Java + * fields. Providers need a public no-argument constructor and {@code @RegisterForReflection} for + * native MCP use. + */ +public interface ApiSchemaProvider { + Schema schema(); + + static Schema type(Schema.SchemaType... types) { + return OASFactory.createSchema().type(List.of(types)); + } + + static ApiSchemaProvider from(org.eclipse.microprofile.openapi.annotations.media.Schema schema) { + if (schema == null || !ApiSchemaProvider.class.isAssignableFrom(schema.implementation())) { + return null; + } + return create(schema.implementation().asSubclass(ApiSchemaProvider.class)); + } + + static ApiSchemaProvider create(Class providerClass) { + try { + return providerClass.getConstructor().newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException( + "Cannot create API schema provider " + providerClass.getName(), e); + } + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java index 40037ad0d8..3f05a3688a 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java @@ -1,20 +1,21 @@ package io.stargate.sgv2.jsonapi.api.v1.mcp; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.victools.jsonschema.generator.CustomDefinition; import com.github.victools.jsonschema.generator.CustomPropertyDefinition; -import com.github.victools.jsonschema.generator.SchemaGenerationContext; import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; import io.quarkiverse.mcp.server.runtime.SchemaGeneratorConfigCustomizer; -import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.FilterDefinition; -import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.SortDefinition; -import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; -import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; +import io.smallrye.openapi.model.OpenApiVersion; +import io.smallrye.openapi.runtime.io.IOContext; +import io.smallrye.openapi.runtime.io.JsonIO; +import io.smallrye.openapi.runtime.io.media.SchemaIO; +import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import jakarta.inject.Singleton; -import java.util.Map; +import org.eclipse.microprofile.openapi.annotations.media.Schema; /** * Generates schemas for Data API clauses whose JSON shape differs from their Java fields. Custom @@ -22,93 +23,16 @@ */ @Singleton public class McpClauseSchemaCustomizer implements SchemaGeneratorConfigCustomizer { - private static final Map, String> CLAUSE_SCHEMAS = - Map.of( - FilterDefinition.class, - """ - { - "type": ["object", "null"], - "description": "A Data API filter object mapping document paths to values or operator expressions, such as $eq, $gt, $in, $and, and $or.", - "additionalProperties": true, - "examples": [{"age": {"$gt": 40}}, {"name": "Aaron", "country": {"$eq": "NZ"}}] - } - """, - SortDefinition.class, - """ - { - "type": ["object", "null"], - "description": "A Data API sort object mapping paths to 1 (ascending) or -1 (descending). Collections also accept a single $vector, $vectorize, or $lexical sort. Tables use the vector or lexical column name.", - "additionalProperties": true, - "examples": [{"user.age": -1, "user.name": 1}, {"$vector": [0.1, 0.2, 0.3]}, {"$vectorize": "search text"}] - } - """, - FindAndRerankSort.class, - """ - { - "type": ["object", "null"], - "properties": { - "$hybrid": { - "anyOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "$vectorize": {"type": ["string", "null"]}, - "$lexical": {"type": ["string", "null"]}, - "$vector": { - "anyOf": [ - {"type": "array", "items": {"type": "number"}}, - {"type": "object", "properties": {"$binary": {"type": "string"}}, "required": ["$binary"], "additionalProperties": false}, - {"type": "null"} - ] - } - }, - "additionalProperties": false, - "not": {"required": ["$vector", "$vectorize"]} - } - ] - } - }, - "additionalProperties": false, - "examples": [ - {"$hybrid": "search text"}, - {"$hybrid": {"$vectorize": "vector query", "$lexical": "lexical query"}}, - {"$hybrid": {"$vector": [0.1, 0.2, 0.3], "$lexical": "lexical query"}}, - {"$hybrid": {"$vector": {"$binary": "P4AAAEAAAABAQAAA"}, "$lexical": "lexical query"}} - ] - } - """, - FindAndRerankCommand.HybridLimits.class, - """ - { - "description": "Candidate read limits as one integer for both legs, or an object containing both $vector and $lexical integer limits. Accepted bounds are determined by server configuration.", - "anyOf": [ - {"type": "integer"}, - { - "type": "object", - "properties": {"$vector": {"type": "integer"}, "$lexical": {"type": "integer"}}, - "required": ["$vector", "$lexical"], - "additionalProperties": false - }, - {"type": "null"} - ], - "examples": [50, {"$vector": 50, "$lexical": 10}] - } - """); + private final SchemaIO schemaIO; - // This override is field-specific: collection creation uses the same service record but may - // advertise authentication and parameters separately from the findAndRerank request override. - private static final String RERANK_OVERRIDE_SCHEMA = - """ - { - "type": ["object", "null"], - "description": "Optional reranking service override. Supply both provider and modelName.", - "properties": {"provider": {"type": "string"}, "modelName": {"type": "string"}}, - "required": ["provider", "modelName"], - "additionalProperties": false, - "examples": [{"provider": "nvidia", "modelName": "nvidia/llama-3.2-nv-rerankqa-1b-v2"}] - } - """; + public McpClauseSchemaCustomizer() { + var context = + IOContext.forJson( + JsonIO.newInstance(null)); + // OpenAPI 3.1 uses the same JSON Schema dialect as the MCP generator. + context.setOpenApiVersion(OpenApiVersion.V3_1); + schemaIO = context.schemaIO(); + } @Override public void customize(SchemaGeneratorConfigBuilder builder) { @@ -117,13 +41,13 @@ public void customize(SchemaGeneratorConfigBuilder builder) { .withIgnoreCheck(field -> field.getType().getErasedType() == CommandFeatures.class) .withCustomDefinitionProvider( (field, context) -> { - if (field.getDeclaringType().getErasedType() == FindAndRerankCommand.Options.class - && field.getDeclaredName().equals("rerankServiceOverride")) { - return new CustomPropertyDefinition( - parseSchema(RERANK_OVERRIDE_SCHEMA, context), - CustomDefinition.AttributeInclusion.NO); - } - return null; + var provider = + ApiSchemaProvider.from( + field.getAnnotationConsideringFieldAndGetter(Schema.class)); + return provider == null + ? null + : new CustomPropertyDefinition( + schemaNode(provider), CustomDefinition.AttributeInclusion.YES); }) .withPropertyNameOverrideResolver( field -> { @@ -135,22 +59,18 @@ public void customize(SchemaGeneratorConfigBuilder builder) { .forTypesInGeneral() .withCustomDefinitionProvider( (javaType, context) -> { - var schemaJson = CLAUSE_SCHEMAS.get(javaType.getErasedType()); - if (schemaJson == null) { - return null; - } - return new CustomDefinition( - parseSchema(schemaJson, context), - CustomDefinition.DefinitionType.INLINE, - CustomDefinition.AttributeInclusion.YES); + var provider = + ApiSchemaProvider.from(javaType.getErasedType().getAnnotation(Schema.class)); + return provider == null + ? null + : new CustomDefinition( + schemaNode(provider), + CustomDefinition.DefinitionType.INLINE, + CustomDefinition.AttributeInclusion.YES); }); } - private static ObjectNode parseSchema(String schemaJson, SchemaGenerationContext context) { - try { - return (ObjectNode) context.getGeneratorConfig().getObjectMapper().readTree(schemaJson); - } catch (JsonProcessingException e) { - throw new IllegalStateException("Invalid MCP clause schema", e); - } + private ObjectNode schemaNode(ApiSchemaProvider provider) { + return (ObjectNode) schemaIO.write(provider.schema()).orElseThrow(); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 06bfcc0eeb..075764f111 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -1,10 +1,12 @@ package io.stargate.sgv2.jsonapi.api.v1.mcp; +import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.*; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.victools.jsonschema.generator.*; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SpecVersion; @@ -21,12 +23,14 @@ import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; import jakarta.inject.Inject; +import java.util.HashSet; import java.util.stream.Stream; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; /** Contracts between advertised MCP schemas and the actual Data API JSON deserializers. */ @QuarkusTest @@ -78,6 +82,92 @@ static Stream> clauseTypes() { FindAndRerankCommand.HybridLimits.class); } + @ParameterizedTest + @MethodSource("clauseTypes") + void openApiAndMcpShareClauseSchemas(Class clauseType) throws Exception { + var provider = clauseType.getAnnotation(Schema.class).implementation(); + var name = provider.getAnnotation(Schema.class).name(); + var openApiSchema = openApi().path("components").path("schemas").path(name); + assertEquals(objectMapper.readTree(schemaFor(clauseType).toString()), openApiSchema); + } + + @Test + void openApiCommandsReferenceSharedSchemas() throws Exception { + var document = openApi(); + var schemas = document.path("components").path("schemas"); + var find = schemas.path("FindCommand").path("properties"); + assertEquals(schemas.path("FilterDefinition"), referencedSchema(document, find.path("filter"))); + assertEquals(schemas.path("SortDefinition"), referencedSchema(document, find.path("sort"))); + var rerank = schemas.path("FindAndRerankCommand").path("properties"); + assertEquals( + schemas.path("FindAndRerankSort"), referencedSchema(document, rerank.path("sort"))); + var options = referencedSchema(document, rerank.path("options")).path("properties"); + assertEquals( + schemas.path("HybridLimits"), referencedSchema(document, options.path("hybridLimits"))); + assertEquals( + schemas.path("RerankServiceOverride"), referencedSchema(document, options.path("rerank"))); + var mcpOverride = + inputSchema("findAndRerank") + .path("properties") + .path("options") + .path("properties") + .path("rerank") + .deepCopy(); + // The field description belongs to the command options, not the reusable schema component. + ((ObjectNode) mcpOverride).remove("description"); + assertEquals(mcpOverride, schemas.path("RerankServiceOverride")); + } + + @ParameterizedTest + @ValueSource(strings = {"50", "{\"$vector\": 50, \"$lexical\": 10}", "null"}) + void openApiAcceptsHybridLimitsWireForms(String limits) throws Exception { + var document = openApi(); + var schema = + objectMapper.createObjectNode().put("$ref", "#/components/schemas/FindAndRerankCommand"); + schema.set("components", document.path("components")); + var command = objectMapper.createObjectNode(); + command.putObject("sort").put("$hybrid", "search text"); + command.putObject("options").set("hybridLimits", objectMapper.readTree(limits)); + assertSchemaAccepts(schema, command); + } + + @Test + void openApiAcceptsNullOptionalClauses() throws Exception { + var schema = + objectMapper.createObjectNode().put("$ref", "#/components/schemas/FindAndRerankCommand"); + schema.set("components", openApi().path("components")); + assertSchemaAccepts( + schema, + objectMapper.readTree( + """ + {"filter": null, "sort": null, "options": {"rerank": null}} + """)); + } + + private JsonNode referencedSchema(JsonNode document, JsonNode schema) { + var visited = new HashSet(); + while (schema.has("$ref")) { + var reference = schema.path("$ref").asText(); + assertTrue(reference.startsWith("#/"), "Expected a local schema reference"); + assertTrue(visited.add(reference), "Circular schema reference"); + schema = document.at(reference.substring(1)); + assertFalse(schema.isMissingNode(), "Unresolved schema reference " + reference); + } + return schema; + } + + private JsonNode openApi() throws Exception { + var json = + given() + .queryParam("format", "json") + .get("/api/json/openapi") + .then() + .statusCode(200) + .extract() + .asString(); + return objectMapper.readTree(json); + } + @Test void rerankToolUsesSortClassDescription() throws Exception { var description = FindAndRerankSort.class.getAnnotation(Schema.class).description(); From f96ac86b9fa009ec293076c2619b5ab844915fe9 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 16:22:24 -0700 Subject: [PATCH 14/35] Check rerank schema requirements against override validation --- .../command/impl/FindAndRerankCommand.java | 11 +- .../v1/mcp/McpClauseSchemaContractTest.java | 102 ++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) 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 940f4832b2..9256689e43 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 @@ -2,6 +2,8 @@ import static io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider.type; import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; +import static io.stargate.sgv2.jsonapi.config.constants.ServiceDescConstants.MODEL_NAME; +import static io.stargate.sgv2.jsonapi.config.constants.ServiceDescConstants.PROVIDER; import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; import com.fasterxml.jackson.annotation.JsonProperty; @@ -139,12 +141,11 @@ public static class RerankOverrideSchema implements ApiSchemaProvider { @Override public org.eclipse.microprofile.openapi.models.media.Schema schema() { return type(OBJECT, NULL) - .addProperty("provider", type(STRING)) - .addProperty("modelName", type(STRING)) - .required(List.of("provider", "modelName")) + .addProperty(PROVIDER, type(STRING)) + .addProperty(MODEL_NAME, type(STRING)) + .required(List.of(PROVIDER, MODEL_NAME)) .additionalPropertiesBoolean(false) - .addExample( - Map.of("provider", "nvidia", "modelName", "nvidia/llama-3.2-nv-rerankqa-1b-v2")); + .addExample(Map.of(PROVIDER, "nvidia", MODEL_NAME, "nvidia/llama-3.2-nv-rerankqa-1b-v2")); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 075764f111..cc818fae6f 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -20,13 +20,25 @@ import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateCollectionCommand; import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; +import io.stargate.sgv2.jsonapi.exception.RequestException; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; +import io.stargate.sgv2.jsonapi.service.provider.ApiModelSupport; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfig; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl.RerankingProviderConfigImpl; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl.RerankingProviderConfigImpl.ModelConfigImpl; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl.RerankingProviderConfigImpl.ModelConfigImpl.RequestPropertiesImpl; +import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionRerankDef; import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; import jakarta.inject.Inject; import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.stream.Stream; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -177,6 +189,96 @@ void rerankToolUsesSortClassDescription() throws Exception { inputSchema("findAndRerank").path("properties").path("sort").path("description").asText()); } + /** + * Required fields are enforced by service validation, so schema acceptance must agree with it. + */ + @ParameterizedTest + @MethodSource("rerankOverrideRequiredFields") + void rerankOverrideSchemaMatchesRequiredFieldValidation(String json, boolean accepted) + throws Exception { + var schema = + inputSchema("findAndRerank") + .path("properties") + .path("options") + .path("properties") + .path("rerank"); + var payload = objectMapper.readTree(json); + var errors = + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(schema) + .validate(payload); + assertEquals( + accepted, errors.isEmpty(), () -> "Schema validation for " + json + " returned " + errors); + + var override = + objectMapper.treeToValue(payload, CreateCollectionCommand.Options.RerankServiceDesc.class); + Executable validate = + () -> + CollectionRerankDef.validateServiceDesc( + supportedRerankingProvider(), + override.provider(), + override.modelName(), + override.authentication(), + override.parameters(), + RequestException.Code.INVALID_RERANK_OVERRIDE); + if (accepted) { + assertDoesNotThrow(validate); + } else { + var error = assertThrows(RequestException.class, validate); + assertEquals(RequestException.Code.INVALID_RERANK_OVERRIDE.name(), error.code); + } + } + + static Stream rerankOverrideRequiredFields() { + return Stream.of( + Arguments.of( + """ + {"provider": "nvidia", "modelName": "nvidia/rerank-v1"} + """, + true), + Arguments.of("{}", false), + Arguments.of( + """ + {"provider": "nvidia"} + """, + false), + Arguments.of( + """ + {"modelName": "nvidia/rerank-v1"} + """, + false), + Arguments.of( + """ + {"provider": null, "modelName": "nvidia/rerank-v1"} + """, + false), + Arguments.of( + """ + {"provider": "nvidia", "modelName": null} + """, + false), + Arguments.of( + """ + {"provider": null, "modelName": null} + """, + false)); + } + + private static RerankingProvidersConfig supportedRerankingProvider() { + var model = + new ModelConfigImpl( + "nvidia/rerank-v1", + new ApiModelSupport.ApiModelSupportImpl( + ApiModelSupport.SupportStatus.SUPPORTED, Optional.empty()), + false, + "https://example.com/rerank", + new RequestPropertiesImpl(3, 10, 100, 100, 0.5, 10)); + return new RerankingProvidersConfigImpl( + Map.of( + "nvidia", + new RerankingProviderConfigImpl(false, "nvidia", true, Map.of(), List.of(model)))); + } + @Test void rerankToolAdvertisesWireFormatAndOverrideName() throws Exception { var schema = inputSchema("findAndRerank"); From ca50f439076f5904a6fba91034c65e45a80a0c37 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 16:25:53 -0700 Subject: [PATCH 15/35] Honor JsonIgnore when generating MCP schemas --- .../clause/sort/FindAndRerankSort.java | 6 +++- .../command/impl/FindAndRerankCommand.java | 3 +- .../api/v1/mcp/McpClauseSchemaCustomizer.java | 8 +++-- .../v1/mcp/McpClauseSchemaContractTest.java | 30 ++++++++++++++++++- 4 files changed, 42 insertions(+), 5 deletions(-) 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 266d9ff0c1..21ecfd48c6 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 @@ -4,6 +4,7 @@ import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import io.quarkus.runtime.annotations.RegisterForReflection; import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; @@ -43,7 +44,10 @@ """ }) public record FindAndRerankSort( - String vectorizeSort, String lexicalSort, float[] vectorSort, CommandFeatures commandFeatures) + String vectorizeSort, + String lexicalSort, + float[] vectorSort, + @JsonIgnore CommandFeatures commandFeatures) implements Recordable { @Schema(name = SchemaProvider.NAME) 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 9256689e43..140d9058a6 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 @@ -6,6 +6,7 @@ import static io.stargate.sgv2.jsonapi.config.constants.ServiceDescConstants.PROVIDER; import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonTypeName; import com.fasterxml.jackson.core.JsonParser; @@ -159,7 +160,7 @@ 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 { @Schema(name = SchemaProvider.NAME) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java index 3f05a3688a..d5763952e6 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java @@ -1,5 +1,6 @@ package io.stargate.sgv2.jsonapi.api.v1.mcp; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -13,7 +14,6 @@ import io.smallrye.openapi.runtime.io.JsonIO; import io.smallrye.openapi.runtime.io.media.SchemaIO; import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; -import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import jakarta.inject.Singleton; import org.eclipse.microprofile.openapi.annotations.media.Schema; @@ -38,7 +38,11 @@ public McpClauseSchemaCustomizer() { public void customize(SchemaGeneratorConfigBuilder builder) { builder .forFields() - .withIgnoreCheck(field -> field.getType().getErasedType() == CommandFeatures.class) + .withIgnoreCheck( + field -> { + var ignore = field.getAnnotationConsideringFieldAndGetter(JsonIgnore.class); + return ignore != null && ignore.value(); + }) .withCustomDefinitionProvider( (field, context) -> { var provider = diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index cc818fae6f..fa4d2f03e4 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -3,6 +3,7 @@ import static io.restassured.RestAssured.given; import static org.junit.jupiter.api.Assertions.*; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -53,7 +54,34 @@ class McpClauseSchemaContractTest { @Inject ToolManager toolManager; record WireNames( - @JsonProperty("wireName") String internalName, CommandFeatures commandFeatures) {} + @JsonProperty("wireName") String internalName, @JsonIgnore CommandFeatures commandFeatures) {} + + record IgnoredFields( + @JsonIgnore String internal, + @JsonIgnore(false) String visible, + CommandFeatures unannotatedFeatures) {} + + static class GetterIgnoredField { + public String internal; + + @JsonIgnore + public String getInternal() { + return internal; + } + } + + @Test + void jsonIgnoreControlsFieldVisibility() { + var properties = schemaFor(IgnoredFields.class).path("properties"); + assertFalse(properties.has("internal")); + assertTrue(properties.has("visible")); + assertTrue(properties.has("unannotatedFeatures")); + } + + @Test + void jsonIgnoreOnGetterHidesField() { + assertFalse(schemaFor(GetterIgnoredField.class).path("properties").has("internal")); + } @Test void fieldsUseJsonPropertyNamesAndHideCommandFeatures() { From 2d4c2042989debb53fe9ab0a68b55c249e786d2a Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 16:33:43 -0700 Subject: [PATCH 16/35] Document schema contracts and test Jackson property names --- .../api/v1/mcp/McpClauseSchemaCustomizer.java | 1 + .../v1/mcp/McpClauseSchemaContractTest.java | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java index d5763952e6..108fc534a4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java @@ -56,6 +56,7 @@ public void customize(SchemaGeneratorConfigBuilder builder) { .withPropertyNameOverrideResolver( field -> { var property = field.getAnnotationConsideringFieldAndGetter(JsonProperty.class); + // Jackson uses an empty value for the Java name. Whitespace is an explicit JSON name. return property != null && !property.value().isEmpty() ? property.value() : null; }); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index fa4d2f03e4..a85594c570 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -45,7 +45,15 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -/** Contracts between advertised MCP schemas and the actual Data API JSON deserializers. */ +/** + * Checks that MCP and OpenAPI advertise the JSON accepted by Data API clause deserializers. + * Reflection alone exposes internal Java fields for clauses with custom deserializers, so those + * clauses need schema overrides. + * + *

Schema validation checks the advertised JSON shape. Deserialization and clause building check + * that the server accepts the examples. Generated tool schemas and the OpenAPI document also verify + * that the overrides reach clients. These checks do not execute commands or require a database. + */ @QuarkusTest @TestProfile(NoGlobalResourcesTestProfile.Impl.class) class McpClauseSchemaContractTest { @@ -56,6 +64,9 @@ class McpClauseSchemaContractTest { record WireNames( @JsonProperty("wireName") String internalName, @JsonIgnore CommandFeatures commandFeatures) {} + record PropertyNameDefaults( + @JsonProperty("") String defaultName, @JsonProperty(" ") String whitespaceName) {} + record IgnoredFields( @JsonIgnore String internal, @JsonIgnore(false) String visible, @@ -91,6 +102,24 @@ void fieldsUseJsonPropertyNamesAndHideCommandFeatures() { assertFalse(properties.has("commandFeatures")); } + @Test + void emptyAndWhitespacePropertyNamesMatchJackson() throws Exception { + var payload = + objectMapper.readTree( + """ + {"defaultName": "default", " ": "explicit"} + """); + var schema = schemaFor(PropertyNameDefaults.class); + var properties = schema.path("properties"); + assertEquals(payload.size(), properties.size()); + payload.fieldNames().forEachRemaining(name -> assertTrue(properties.has(name))); + assertSchemaAccepts(schema, payload); + assertEquals( + new PropertyNameDefaults("default", "explicit"), + objectMapper.treeToValue(payload, PropertyNameDefaults.class)); + } + + /** An example must satisfy both its advertised schema and the server's clause parser. */ @ParameterizedTest @MethodSource("clauseTypes") void everyAdvertisedClauseExampleDeserializes(Class clauseType) throws Exception { @@ -106,6 +135,7 @@ void everyAdvertisedClauseExampleDeserializes(Class clauseType) throws Except assertSchemaAccepts(schema, example); var clause = objectMapper.treeToValue(example, clauseType); assertNotNull(clause); + // These two clauses defer parsing until build, so deserialization alone cannot validate them. if (clause instanceof FilterDefinition filter) { assertNotNull(filter.build(new TestConstants().collectionContext())); } else if (clause instanceof SortDefinition sort) { @@ -122,6 +152,7 @@ static Stream> clauseTypes() { FindAndRerankCommand.HybridLimits.class); } + /** Both entry points accept the same clause JSON and must expose the same schema definition. */ @ParameterizedTest @MethodSource("clauseTypes") void openApiAndMcpShareClauseSchemas(Class clauseType) throws Exception { @@ -131,6 +162,7 @@ void openApiAndMcpShareClauseSchemas(Class clauseType) throws Exception { assertEquals(objectMapper.readTree(schemaFor(clauseType).toString()), openApiSchema); } + /** A correct reusable schema has no effect unless command fields actually reference it. */ @Test void openApiCommandsReferenceSharedSchemas() throws Exception { var document = openApi(); @@ -158,6 +190,7 @@ void openApiCommandsReferenceSharedSchemas() throws Exception { assertEquals(mcpOverride, schemas.path("RerankServiceOverride")); } + /** Check the complete command schema because inferred parent types can restrict valid clauses. */ @ParameterizedTest @ValueSource(strings = {"50", "{\"$vector\": 50, \"$lexical\": 10}", "null"}) void openApiAcceptsHybridLimitsWireForms(String limits) throws Exception { @@ -372,6 +405,7 @@ void openApiSortExamplesAreValidClauseJson() throws Exception { } } + /** Positive examples alone would also pass against a schema that accepts every JSON value. */ @ParameterizedTest @MethodSource("invalidWireShapes") void schemaRejectsInvalidWireShapes(Class clauseType, String json) throws Exception { @@ -421,6 +455,7 @@ void collectionCreationKeepsItsOwnRerankServiceFields() { } private JsonNode inputSchema(String name) throws Exception { + // The registered tool includes nested command fields and the application's customizer wiring. var tool = toolManager.getTool(name); assertNotNull(tool); return objectMapper.readTree(schemaGenerator.generate(tool).asJson()); From e1c457ebd3d78d7707cec19f99f4fb7cc808e75c Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 16:38:49 -0700 Subject: [PATCH 17/35] Discover clause schemas and validate tool payloads --- .../v1/mcp/McpClauseSchemaContractTest.java | 168 ++++++++++++------ 1 file changed, 113 insertions(+), 55 deletions(-) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index a85594c570..167c63954b 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -21,6 +21,7 @@ import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateCollectionCommand; import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; +import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.exception.RequestException; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.service.provider.ApiModelSupport; @@ -32,12 +33,16 @@ import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionRerankDef; import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; import jakarta.inject.Inject; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.stream.Stream; import org.eclipse.microprofile.openapi.annotations.media.Schema; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.IndexReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.params.ParameterizedTest; @@ -144,12 +149,26 @@ void everyAdvertisedClauseExampleDeserializes(Class clauseType) throws Except } } - static Stream> clauseTypes() { - return Stream.of( - FilterDefinition.class, - SortDefinition.class, - FindAndRerankSort.class, - FindAndRerankCommand.HybridLimits.class); + static Stream> clauseTypes() throws Exception { + var loader = ApiSchemaProvider.class.getClassLoader(); + // Use the build's class index so new annotated clauses join these tests automatically. + try (var indexStream = loader.getResourceAsStream("META-INF/jandex.idx")) { + assertNotNull(indexStream, "The application class index must be available"); + var index = new IndexReader(indexStream).read(); + var clauseTypes = new ArrayList>(); + for (var annotation : index.getAnnotations(Schema.class)) { + if (annotation.target().kind() != AnnotationTarget.Kind.CLASS) { + continue; + } + var type = Class.forName(annotation.target().asClass().name().toString(), false, loader); + var provider = type.getAnnotation(Schema.class).implementation(); + if (ApiSchemaProvider.class.isAssignableFrom(provider)) { + clauseTypes.add(type); + } + } + assertFalse(clauseTypes.isEmpty(), "The application index must contain clause schemas"); + return clauseTypes.stream().sorted(Comparator.comparing(Class::getName)); + } } /** Both entry points accept the same clause JSON and must expose the same schema definition. */ @@ -340,32 +359,30 @@ private static RerankingProvidersConfig supportedRerankingProvider() { new RerankingProviderConfigImpl(false, "nvidia", true, Map.of(), List.of(model)))); } - @Test - void rerankToolAdvertisesWireFormatAndOverrideName() throws Exception { - var schema = inputSchema("findAndRerank"); - var properties = schema.path("properties"); - assertTrue(properties.path("sort").path("properties").has("$hybrid")); - var options = properties.path("options").path("properties"); - assertTrue(options.has("rerank")); - assertFalse(options.has("rerankServiceOverride")); - var hybridLimits = options.path("hybridLimits"); - assertSchemaAccepts(hybridLimits, objectMapper.readTree("50")); - assertSchemaAccepts(hybridLimits, objectMapper.readTree("{\"$vector\": 50, \"$lexical\": 10}")); - var rerankProperties = options.path("rerank").path("properties"); - assertTrue(rerankProperties.has("provider")); - assertTrue(rerankProperties.has("modelName")); - assertFalse(rerankProperties.has("authentication")); - assertFalse(rerankProperties.has("parameters")); - assertFalse(schema.toString().contains("commandFeatures")); - assertFalse(schema.toString().contains("vectorLimit")); + @ParameterizedTest + @ValueSource(strings = {"50", "{\"$vector\": 50, \"$lexical\": 10}"}) + void rerankToolAcceptsWirePayloads(String limits) throws Exception { + var payload = + toolInput( + """ + { + "filter": {"age": {"$gt": 40}}, + "sort": {"$hybrid": "search text"}, + "options": {"rerank": {"provider": "nvidia", "modelName": "nvidia/rerank-v1"}} + } + """); + ((ObjectNode) payload.path("options")).set("hybridLimits", objectMapper.readTree(limits)); + assertSchemaAccepts(inputSchema("findAndRerank"), payload); + } - var sortSchema = properties.path("sort"); - assertFalse(sortSchema.path("examples").isEmpty()); - for (var example : sortSchema.path("examples")) { - assertSchemaAccepts(sortSchema, example); - assertNotNull(objectMapper.treeToValue(example, FindAndRerankSort.class)); - } - var rerankSchema = options.path("rerank"); + @Test + void rerankOverrideExamplesDeserialize() throws Exception { + var rerankSchema = + inputSchema("findAndRerank") + .path("properties") + .path("options") + .path("properties") + .path("rerank"); assertFalse(rerankSchema.path("examples").isEmpty()); for (var example : rerankSchema.path("examples")) { assertSchemaAccepts(rerankSchema, example); @@ -373,28 +390,55 @@ void rerankToolAdvertisesWireFormatAndOverrideName() throws Exception { objectMapper.treeToValue( example, CreateCollectionCommand.Options.RerankServiceDesc.class)); } - var limitExamples = hybridLimits.findValues("examples"); - assertFalse(limitExamples.isEmpty()); - for (var example : limitExamples.getFirst()) { - assertSchemaAccepts(hybridLimits, example); - assertNotNull(objectMapper.treeToValue(example, FindAndRerankCommand.HybridLimits.class)); - } } @Test - void findToolAdvertisesPlainFilterAndSortClauses() throws Exception { - var properties = inputSchema("find").path("properties"); - var filter = properties.path("filter"); - var sort = properties.path("sort"); - assertFalse(filter.has("properties")); - assertFalse(sort.toString().contains("sortClause")); - assertTrue(filter.path("examples").size() > 0); - assertTrue(sort.path("examples").size() > 0); - var filterExample = filter.path("examples").get(0); - var sortExample = sort.path("examples").get(0); - assertEquals( - filterExample, objectMapper.treeToValue(filterExample, FilterDefinition.class).json()); - assertEquals(sortExample, objectMapper.treeToValue(sortExample, SortDefinition.class).json()); + void findToolAcceptsWirePayloads() throws Exception { + var payload = + toolInput( + """ + {"filter": {"age": {"$gt": 40}}, "sort": {"user.age": -1, "user.name": 1}} + """); + assertSchemaAccepts(inputSchema("find"), payload); + } + + @ParameterizedTest + @MethodSource("invalidToolPayloads") + void toolSchemasRejectInvalidPayloads(String tool, String json) throws Exception { + var schema = + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(inputSchema(tool)); + assertFalse(schema.validate(toolInput(json)).isEmpty()); + } + + static Stream invalidToolPayloads() { + return Stream.of( + Arguments.of("find", "{\"filter\": 5}"), + Arguments.of("find", "{\"sort\": []}"), + Arguments.of("findAndRerank", "{\"sort\": {\"vectorizeSort\": \"query\"}}"), + Arguments.of("findAndRerank", "{\"sort\": {\"$hybrid\": {\"$vector\": \"query\"}}}"), + Arguments.of("findAndRerank", "{\"options\": {\"hybridLimits\": {\"$vector\": 50}}}"), + Arguments.of("findAndRerank", "{\"options\": {\"rerank\": {\"provider\": \"nvidia\"}}}"), + Arguments.of( + "findAndRerank", + """ + {"options": {"rerank": { + "provider": "nvidia", "modelName": "nvidia/rerank-v1", "authentication": {} + }}} + """), + Arguments.of( + "findAndRerank", + """ + {"options": {"rerank": { + "provider": "nvidia", "modelName": "nvidia/rerank-v1", "parameters": {} + }}} + """)); + } + + private ObjectNode toolInput(String json) throws Exception { + // Routing arguments are required by MCP but are not part of the command clauses. + return ((ObjectNode) objectMapper.readTree(json)) + .put("keyspace", "default_keyspace") + .put("collection", "documents"); } @Test @@ -447,11 +491,25 @@ private void assertSchemaAccepts(JsonNode schema, JsonNode example) { } @Test - void collectionCreationKeepsItsOwnRerankServiceFields() { - var properties = - schemaFor(CreateCollectionCommand.Options.RerankServiceDesc.class).path("properties"); - assertTrue(properties.has("authentication")); - assertTrue(properties.has("parameters")); + void collectionCreationAcceptsRerankAuthenticationAndParameters() throws Exception { + var payload = + objectMapper.readTree( + """ + { + "provider": "nvidia", "modelName": "nvidia/rerank-v1", + "authentication": {"providerKey": "credential-reference"}, + "parameters": {"truncate": "END"} + } + """); + assertSchemaAccepts( + schemaFor(CreateCollectionCommand.Options.RerankServiceDesc.class), payload); + assertEquals( + new CreateCollectionCommand.Options.RerankServiceDesc( + "nvidia", + "nvidia/rerank-v1", + Map.of("providerKey", "credential-reference"), + Map.of("truncate", "END")), + objectMapper.treeToValue(payload, CreateCollectionCommand.Options.RerankServiceDesc.class)); } private JsonNode inputSchema(String name) throws Exception { From 41015d3ebdafe0ac6a5af34bec39d873e1cf322c Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 16:41:25 -0700 Subject: [PATCH 18/35] Combine MCP schema customizers and focus example tests --- ...stomizer.java => McpSchemaCustomizer.java} | 25 +++++++++-- .../mcp/McpSchemaDescriptionCustomizer.java | 43 ------------------- .../v1/mcp/McpClauseSchemaContractTest.java | 7 +-- ...Test.java => McpSchemaCustomizerTest.java} | 12 +++--- 4 files changed, 28 insertions(+), 59 deletions(-) rename src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/{McpClauseSchemaCustomizer.java => McpSchemaCustomizer.java} (75%) delete mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizer.java rename src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/{McpSchemaDescriptionCustomizerTest.java => McpSchemaCustomizerTest.java} (91%) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java similarity index 75% rename from src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java rename to src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java index 108fc534a4..50ce5430e4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java @@ -18,14 +18,17 @@ import org.eclipse.microprofile.openapi.annotations.media.Schema; /** - * Generates schemas for Data API clauses whose JSON shape differs from their Java fields. Custom - * deserializers and delegating creators cannot be described by reflecting their fields. + * Generates MCP schemas using Data API clause definitions and Jackson and OpenAPI annotations. + * Custom deserializers and delegating creators cannot be described by reflecting their fields. + * + *

The generator's PLAIN_JSON preset does not read annotations. This customizer supplies field + * names, ignored fields, descriptions, and schema overrides. */ @Singleton -public class McpClauseSchemaCustomizer implements SchemaGeneratorConfigCustomizer { +public class McpSchemaCustomizer implements SchemaGeneratorConfigCustomizer { private final SchemaIO schemaIO; - public McpClauseSchemaCustomizer() { + public McpSchemaCustomizer() { var context = IOContext.forJson( JsonIO.newInstance(null)); @@ -38,6 +41,13 @@ public McpClauseSchemaCustomizer() { public void customize(SchemaGeneratorConfigBuilder builder) { builder .forFields() + .withDescriptionResolver( + field -> { + var schema = field.getAnnotationConsideringFieldAndGetter(Schema.class); + return schema != null && !schema.description().isEmpty() + ? schema.description() + : null; + }) .withIgnoreCheck( field -> { var ignore = field.getAnnotationConsideringFieldAndGetter(JsonIgnore.class); @@ -62,6 +72,13 @@ public void customize(SchemaGeneratorConfigBuilder builder) { builder .forTypesInGeneral() + .withDescriptionResolver( + type -> { + var schema = type.getType().getErasedType().getAnnotation(Schema.class); + return schema != null && !schema.description().isEmpty() + ? schema.description() + : null; + }) .withCustomDefinitionProvider( (javaType, context) -> { var provider = diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizer.java deleted file mode 100644 index 010216df29..0000000000 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizer.java +++ /dev/null @@ -1,43 +0,0 @@ -package io.stargate.sgv2.jsonapi.api.v1.mcp; - -import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; -import io.quarkiverse.mcp.server.runtime.SchemaGeneratorConfigCustomizer; -import jakarta.inject.Singleton; -import org.eclipse.microprofile.openapi.annotations.media.Schema; - -/** - * Enables MicroProfile {@link Schema#description()} resolution for the victools JSON schema - * generator used by Quarkus MCP Server. Without this customizer, classes and record fields - * annotated with {@code @Schema(description = "...")} will not have their descriptions included in - * the MCP tool schema output. - * - *

The Quarkus MCP Server's {@code DefaultSchemaGenerator} uses {@code OptionPreset.PLAIN_JSON}, - * which does not read any annotations by default. This customizer bridges that gap by adding field - * and type description resolvers for MicroProfile OpenAPI's {@code @Schema} annotation. - */ -@Singleton -public class McpSchemaDescriptionCustomizer implements SchemaGeneratorConfigCustomizer { - - @Override - public void customize(SchemaGeneratorConfigBuilder builder) { - builder - .forTypesInGeneral() - .withDescriptionResolver( - type -> { - Schema schemaAnn = type.getType().getErasedType().getAnnotation(Schema.class); - return schemaAnn != null && !schemaAnn.description().isEmpty() - ? schemaAnn.description() - : null; - }); - - builder - .forFields() - .withDescriptionResolver( - field -> { - Schema schemaAnn = field.getAnnotationConsideringFieldAndGetter(Schema.class); - return schemaAnn != null && !schemaAnn.description().isEmpty() - ? schemaAnn.description() - : null; - }); - } -} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 167c63954b..c039ed429a 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -129,10 +129,6 @@ void emptyAndWhitespacePropertyNamesMatchJackson() throws Exception { @MethodSource("clauseTypes") void everyAdvertisedClauseExampleDeserializes(Class clauseType) throws Exception { var schema = schemaFor(clauseType); - assertFalse(schema.toString().contains("commandFeatures")); - assertFalse(schema.toString().contains("filterClause")); - assertFalse(schema.toString().contains("sortClause")); - assertFalse(schema.toString().contains("vectorizeSort")); var examples = schema.get("examples"); assertNotNull(examples, "Clause schema must advertise examples of the accepted JSON"); assertFalse(examples.isEmpty()); @@ -524,8 +520,7 @@ private JsonNode schemaFor(Class type) { new SchemaGeneratorConfigBuilder( objectMapper, SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) .without(Option.SCHEMA_VERSION_INDICATOR); - new McpSchemaDescriptionCustomizer().customize(builder); - new McpClauseSchemaCustomizer().customize(builder); + new McpSchemaCustomizer().customize(builder); return new SchemaGenerator(builder.build()).generateSchema(type); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java similarity index 91% rename from src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizerTest.java rename to src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java index d7d905002f..7d417fb247 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaDescriptionCustomizerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java @@ -11,10 +11,10 @@ import org.junit.jupiter.api.Test; /** - * Unit tests for {@link McpSchemaDescriptionCustomizer}. Verifies that the customizer correctly - * resolves {@link Schema#description()} into the generated JSON schema. + * Unit tests for {@link McpSchemaCustomizer}. Verifies that the customizer correctly resolves + * {@link Schema#description()} into the generated JSON schema. */ -class McpSchemaDescriptionCustomizerTest { +class McpSchemaCustomizerTest { /** * Test record that mimics nested command structures such as {@link @@ -45,7 +45,7 @@ record TestTypeDescription(TestDescribedType value) {} @Test void shouldIncludeRootTypeDescription() { var configBuilder = baseConfigBuilder(); - new McpSchemaDescriptionCustomizer().customize(configBuilder); + new McpSchemaCustomizer().customize(configBuilder); var schema = new SchemaGenerator(configBuilder.build()).generateSchema(TestDescribedType.class); @@ -55,7 +55,7 @@ void shouldIncludeRootTypeDescription() { @Test void shouldIncludeNestedTypeDescriptionWithoutFieldDescription() { var configBuilder = baseConfigBuilder(); - new McpSchemaDescriptionCustomizer().customize(configBuilder); + new McpSchemaCustomizer().customize(configBuilder); var schema = new SchemaGenerator(configBuilder.build()).generateSchema(TestTypeDescription.class); @@ -68,7 +68,7 @@ void shouldIncludeNestedTypeDescriptionWithoutFieldDescription() { @Test void shouldIncludeSchemaDescriptionWithCustomizer() { var configBuilder = baseConfigBuilder(); - new McpSchemaDescriptionCustomizer().customize(configBuilder); + new McpSchemaCustomizer().customize(configBuilder); var schema = new SchemaGenerator(configBuilder.build()).generateSchema(TestCreateTable.class); var props = schema.get("properties"); From 53ce63e8daa605908fd1234e44495f2856cfa48f Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Mon, 19 Oct 2026 16:46:13 -0700 Subject: [PATCH 19/35] Cache MCP schema definitions with independent copies --- .../api/model/schema/ApiSchemaProvider.java | 7 -- .../api/v1/mcp/McpSchemaCustomizer.java | 35 ++++++--- .../api/v1/mcp/McpSchemaCustomizerTest.java | 78 ++++++++++++++++++- 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java index bfa1273370..fb80b01052 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java @@ -18,13 +18,6 @@ static Schema type(Schema.SchemaType... types) { return OASFactory.createSchema().type(List.of(types)); } - static ApiSchemaProvider from(org.eclipse.microprofile.openapi.annotations.media.Schema schema) { - if (schema == null || !ApiSchemaProvider.class.isAssignableFrom(schema.implementation())) { - return null; - } - return create(schema.implementation().asSubclass(ApiSchemaProvider.class)); - } - static ApiSchemaProvider create(Class providerClass) { try { return providerClass.getConstructor().newInstance(); diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java index 50ce5430e4..719870316a 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java @@ -15,6 +15,8 @@ import io.smallrye.openapi.runtime.io.media.SchemaIO; import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import jakarta.inject.Singleton; +import java.util.HashMap; +import java.util.Map; import org.eclipse.microprofile.openapi.annotations.media.Schema; /** @@ -27,6 +29,7 @@ @Singleton public class McpSchemaCustomizer implements SchemaGeneratorConfigCustomizer { private final SchemaIO schemaIO; + private final Map, ObjectNode> schemas = new HashMap<>(); public McpSchemaCustomizer() { var context = @@ -55,13 +58,10 @@ public void customize(SchemaGeneratorConfigBuilder builder) { }) .withCustomDefinitionProvider( (field, context) -> { - var provider = - ApiSchemaProvider.from( - field.getAnnotationConsideringFieldAndGetter(Schema.class)); - return provider == null + var schema = schemaNode(field.getAnnotationConsideringFieldAndGetter(Schema.class)); + return schema == null ? null - : new CustomPropertyDefinition( - schemaNode(provider), CustomDefinition.AttributeInclusion.YES); + : new CustomPropertyDefinition(schema, CustomDefinition.AttributeInclusion.YES); }) .withPropertyNameOverrideResolver( field -> { @@ -81,18 +81,29 @@ public void customize(SchemaGeneratorConfigBuilder builder) { }) .withCustomDefinitionProvider( (javaType, context) -> { - var provider = - ApiSchemaProvider.from(javaType.getErasedType().getAnnotation(Schema.class)); - return provider == null + var schema = schemaNode(javaType.getErasedType().getAnnotation(Schema.class)); + return schema == null ? null : new CustomDefinition( - schemaNode(provider), + schema, CustomDefinition.DefinitionType.INLINE, CustomDefinition.AttributeInclusion.YES); }); } - private ObjectNode schemaNode(ApiSchemaProvider provider) { - return (ObjectNode) schemaIO.write(provider.schema()).orElseThrow(); + private synchronized ObjectNode schemaNode(Schema annotation) { + if (annotation == null + || !ApiSchemaProvider.class.isAssignableFrom(annotation.implementation())) { + return null; + } + var providerClass = annotation.implementation().asSubclass(ApiSchemaProvider.class); + // Serialize each provider once. Synchronization also protects the shared OpenAPI writer. + var schema = + schemas.computeIfAbsent( + providerClass, + type -> + (ObjectNode) schemaIO.write(ApiSchemaProvider.create(type).schema()).orElseThrow()); + // The generator adds field metadata, so each use needs its own mutable copy. + return schema.deepCopy(); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java index 7d417fb247..5d3c96c987 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java @@ -2,17 +2,21 @@ import static org.junit.jupiter.api.Assertions.*; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.victools.jsonschema.generator.Option; import com.github.victools.jsonschema.generator.OptionPreset; import com.github.victools.jsonschema.generator.SchemaGenerator; import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; import com.github.victools.jsonschema.generator.SchemaVersion; +import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; +import java.util.concurrent.atomic.AtomicInteger; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.junit.jupiter.api.Test; /** - * Unit tests for {@link McpSchemaCustomizer}. Verifies that the customizer correctly resolves - * {@link Schema#description()} into the generated JSON schema. + * Checks description resolution and reuse of independent schema copies in {@link + * McpSchemaCustomizer}. */ class McpSchemaCustomizerTest { @@ -42,6 +46,76 @@ record TestDescribedType(String value) {} record TestTypeDescription(TestDescribedType value) {} + @Schema(implementation = CountingSchemaProvider.class) + record TestCachedType(String value) {} + + record TestCachedFields( + @Schema(implementation = CountingSchemaProvider.class, description = "First field") + TestCachedType first, + @Schema(implementation = CountingSchemaProvider.class, description = "Second field") + TestCachedType second) {} + + @Schema(name = "CustomizerTestSchema") + public static class CountingSchemaProvider implements ApiSchemaProvider { + static final AtomicInteger instances = new AtomicInteger(); + static final AtomicInteger schemasBuilt = new AtomicInteger(); + + public CountingSchemaProvider() { + instances.incrementAndGet(); + } + + @Override + public org.eclipse.microprofile.openapi.models.media.Schema schema() { + schemasBuilt.incrementAndGet(); + return ApiSchemaProvider.type( + org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.OBJECT) + .addProperty( + "value", + ApiSchemaProvider.type( + org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.STRING)); + } + } + + @Test + void shouldCacheSchemaAcrossTypesAndFields() { + CountingSchemaProvider.instances.set(0); + CountingSchemaProvider.schemasBuilt.set(0); + var customizer = new McpSchemaCustomizer(); + + schemaFor(customizer, TestCachedType.class); + schemaFor(customizer, TestCachedFields.class); + schemaFor(customizer, TestCachedType.class); + + assertEquals(1, CountingSchemaProvider.instances.get()); + assertEquals(1, CountingSchemaProvider.schemasBuilt.get()); + } + + @Test + void shouldIsolateCachedSchemaFromGeneratedNodes() { + var customizer = new McpSchemaCustomizer(); + var schema = schemaFor(customizer, TestCachedType.class); + var original = schema.deepCopy(); + ((ObjectNode) schema.path("properties").path("value")).put("type", "integer"); + + assertEquals(original, schemaFor(customizer, TestCachedType.class)); + } + + @Test + void shouldKeepFieldDescriptionsIndependentWhenSharingCachedSchema() { + var customizer = new McpSchemaCustomizer(); + var properties = schemaFor(customizer, TestCachedFields.class).path("properties"); + + assertEquals("First field", properties.path("first").path("description").asText()); + assertEquals("Second field", properties.path("second").path("description").asText()); + assertFalse(schemaFor(customizer, TestCachedType.class).has("description")); + } + + private static JsonNode schemaFor(McpSchemaCustomizer customizer, Class type) { + var builder = baseConfigBuilder(); + customizer.customize(builder); + return new SchemaGenerator(builder.build()).generateSchema(type); + } + @Test void shouldIncludeRootTypeDescription() { var configBuilder = baseConfigBuilder(); From 04bf94533f8de304de6af5a40ddc74a5c5cd1279 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 20 Oct 2026 16:28:03 -0700 Subject: [PATCH 20/35] Reuse annotation-based OpenAPI schemas for MCP inputs --- .../api/model/command/JsonDefinition.java | 2 + .../clause/filter/FilterDefinition.java | 41 +-- .../command/clause/filter/SortDefinition.java | 47 ++-- .../clause/sort/FindAndRerankSort.java | 122 ++++----- .../command/impl/FindAndRerankCommand.java | 96 +++---- .../api/model/schema/ApiSchemaFilter.java | 76 ----- .../api/model/schema/ApiSchemaProvider.java | 29 -- .../api/v1/mcp/McpOpenApiSchemaAdapter.java | 149 ++++++++++ .../api/v1/mcp/McpSchemaCustomizer.java | 66 ++--- .../v1/mcp/McpClauseSchemaContractTest.java | 207 +++++++++++--- .../v1/mcp/McpOpenApiSchemaAdapterTest.java | 259 ++++++++++++++++++ .../api/v1/mcp/McpSchemaCustomizerTest.java | 143 +++++----- 12 files changed, 807 insertions(+), 430 deletions(-) delete mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaFilter.java delete mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java create mode 100644 src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapter.java create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java 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..592c37981c 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 @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.google.common.annotations.VisibleForTesting; import java.util.Objects; +import org.eclipse.microprofile.openapi.annotations.media.Schema; /** * Value type enclosing JSON-encoded representation of something to build into full model object, @@ -16,6 +17,7 @@ */ public abstract class JsonDefinition { /** The wrapped JSON value */ + @Schema(hidden = true) private final JsonNode json; protected JsonDefinition(JsonNode 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 a456fc45f7..838dc43de3 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,17 +1,12 @@ package io.stargate.sgv2.jsonapi.api.model.command.clause.filter; -import static io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider.type; -import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; - import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.databind.JsonNode; -import io.quarkus.runtime.annotations.RegisterForReflection; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.JsonDefinition; import io.stargate.sgv2.jsonapi.api.model.command.builders.FilterClauseBuilder; -import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.config.OperationsConfig; -import java.util.Map; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.media.Schema; /** @@ -19,35 +14,27 @@ * allows for lazy deserialization into a {@link FilterClause}. */ @Schema( + name = "FilterDefinition", + type = SchemaType.OBJECT, + implementation = Object.class, + nullable = true, + additionalProperties = Schema.True.class, description = "A Data API filter object mapping document paths to values or operator expressions, such as $eq, $gt, $in, $and, and $or.", - implementation = FilterDefinition.SchemaProvider.class, - ref = FilterDefinition.SchemaProvider.REF, - examples = - """ + examples = { + """ + {"age": {"$gt": 40}} + """, + """ {"name": "Aaron", "country": {"$eq": "NZ"}, "age": {"$gt": 40}} - """) + """ + }) public class FilterDefinition extends JsonDefinition { - @Schema(name = SchemaProvider.NAME) - @RegisterForReflection - public static class SchemaProvider implements ApiSchemaProvider { - public static final String NAME = "FilterDefinition"; - public static final String REF = "#/components/schemas/" + NAME; - - @Override - public org.eclipse.microprofile.openapi.models.media.Schema schema() { - return type(OBJECT, NULL) - .description(FilterDefinition.class.getAnnotation(Schema.class).description()) - .additionalPropertiesBoolean(true) - .addExample(Map.of("age", Map.of("$gt", 40))) - .addExample(Map.of("name", "Aaron", "country", Map.of("$eq", "NZ"))); - } - } - /** * Lazily deserialized {@link FilterClause} from the JSON value. We need this due to existing * reliance on specific stateful instances of {@link FilterClause}. */ + @Schema(hidden = true) private FilterClause filterClause; /** 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 0bde1bf082..85347fc41c 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,21 +1,15 @@ package io.stargate.sgv2.jsonapi.api.model.command.clause.filter; -import static io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider.type; -import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; -import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; - import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import io.quarkus.runtime.annotations.RegisterForReflection; import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; import io.stargate.sgv2.jsonapi.api.model.command.JsonDefinition; import io.stargate.sgv2.jsonapi.api.model.command.builders.SortClauseBuilder; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.SortClause; -import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import java.util.List; -import java.util.Map; +import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; import org.eclipse.microprofile.openapi.annotations.media.Schema; /** @@ -23,36 +17,30 @@ * for lazy deserialization into a {@link SortClause}. */ @Schema( + name = "SortDefinition", + type = SchemaType.OBJECT, + implementation = Object.class, + nullable = true, + additionalProperties = Schema.True.class, description = "A Data API sort object mapping paths to 1 (ascending) or -1 (descending). Collections also accept a single $vector, $vectorize, or $lexical sort. Tables use the vector or lexical column name.", - implementation = SortDefinition.SchemaProvider.class, - ref = SortDefinition.SchemaProvider.REF, - examples = - """ + examples = { + """ {"user.age" : -1, "user.name" : 1} - """) + """, + """ + {"$vector": [0.1, 0.2, 0.3]} + """, + """ + {"$vectorize": "search text"} + """ + }) public class SortDefinition extends JsonDefinition { - @Schema(name = SchemaProvider.NAME) - @RegisterForReflection - public static class SchemaProvider implements ApiSchemaProvider { - public static final String NAME = "SortDefinition"; - public static final String REF = "#/components/schemas/" + NAME; - - @Override - public org.eclipse.microprofile.openapi.models.media.Schema schema() { - return type(OBJECT, NULL) - .description(SortDefinition.class.getAnnotation(Schema.class).description()) - .additionalPropertiesBoolean(true) - .addExample(Map.of("user.age", -1, "user.name", 1)) - .addExample(Map.of(VECTOR_EMBEDDING_FIELD, List.of(0.1, 0.2, 0.3))) - .addExample(Map.of(VECTOR_EMBEDDING_TEXT_FIELD, "search text")); - } - } - /** * Lazily deserialized {@link SortClause} from the JSON value. We need this due to existing * reliance on specific stateful instances of {@link SortClause}. */ + @Schema(hidden = true) private SortClause sortClause; /** @@ -109,6 +97,7 @@ public boolean hasVsearchClause() { * Helper method needed for logging purposes, where caller does not have access to the {@link * CommandContext}. */ + @Schema(hidden = true) 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 21ecfd48c6..d1b9ce9d61 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,18 +1,15 @@ package io.stargate.sgv2.jsonapi.api.model.command.clause.sort; -import static io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider.type; import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; -import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import io.quarkus.runtime.annotations.RegisterForReflection; -import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.util.recordable.Recordable; import java.util.*; -import org.eclipse.microprofile.openapi.OASFactory; +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 @@ -26,12 +23,19 @@ */ @JsonDeserialize(using = FindAndRerankSortClauseDeserializer.class) @Schema( + name = "FindAndRerankSort", + type = SchemaType.OBJECT, + implementation = Object.class, + 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." + " Query text and required collection capabilities are checked when resolving the command.", - implementation = FindAndRerankSort.SchemaProvider.class, - ref = FindAndRerankSort.SchemaProvider.REF, examples = { """ {"$hybrid": "Same query for vectorize and BM25 sorting"} @@ -41,76 +45,54 @@ """, """ {"$hybrid": {"$vector": [1, 2, 3], "$lexical": "lexical sort"}} + """, + """ + {"$hybrid": {"$vector": {"$binary": "P4AAAEAAAABAQAAA"}, "$lexical": "lexical query"}} """ }) public record FindAndRerankSort( - String vectorizeSort, - String lexicalSort, - float[] vectorSort, + @Schema(hidden = true) String vectorizeSort, + @Schema(hidden = true) String lexicalSort, + @Schema(hidden = true) float[] vectorSort, @JsonIgnore CommandFeatures commandFeatures) implements Recordable { - @Schema(name = SchemaProvider.NAME) - @RegisterForReflection - public static class SchemaProvider implements ApiSchemaProvider { - public static final String NAME = "FindAndRerankSort"; - public static final String REF = "#/components/schemas/" + NAME; + // Java annotations reference helper classes to describe nested schemas. + @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 {} - @Override - public org.eclipse.microprofile.openapi.models.media.Schema schema() { - var vector = - OASFactory.createSchema() - .addAnyOf(type(ARRAY).items(type(NUMBER))) - .addAnyOf( - type(OBJECT) - .addProperty(BINARY_VECTOR_TEXT_FIELD, type(STRING)) - .addRequired(BINARY_VECTOR_TEXT_FIELD) - .additionalPropertiesBoolean(false)) - .addAnyOf(type(NULL)); - var hybrid = - OASFactory.createSchema() - .addAnyOf(type(STRING)) - .addAnyOf( - type(OBJECT) - .addProperty(VECTOR_EMBEDDING_TEXT_FIELD, type(STRING, NULL)) - .addProperty(LEXICAL_CONTENT_FIELD, type(STRING, NULL)) - .addProperty(VECTOR_EMBEDDING_FIELD, vector) - .additionalPropertiesBoolean(false) - .not( - OASFactory.createSchema() - .required( - List.of(VECTOR_EMBEDDING_FIELD, VECTOR_EMBEDDING_TEXT_FIELD)))); - return type(OBJECT, NULL) - .description(FindAndRerankSort.class.getAnnotation(Schema.class).description()) - .addProperty(HYBRID_FIELD, hybrid) - .additionalPropertiesBoolean(false) - .addExample(Map.of(HYBRID_FIELD, "search text")) - .addExample( - Map.of( - HYBRID_FIELD, - Map.of( - VECTOR_EMBEDDING_TEXT_FIELD, - "vector query", - LEXICAL_CONTENT_FIELD, - "lexical query"))) - .addExample( - Map.of( - HYBRID_FIELD, - Map.of( - VECTOR_EMBEDDING_FIELD, - List.of(0.1, 0.2, 0.3), - LEXICAL_CONTENT_FIELD, - "lexical query"))) - .addExample( - Map.of( - HYBRID_FIELD, - Map.of( - VECTOR_EMBEDDING_FIELD, - Map.of(BINARY_VECTOR_TEXT_FIELD, "P4AAAEAAAABAQAAA"), - LEXICAL_CONTENT_FIELD, - "lexical query"))); - } - } + @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 {} + + @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 140d9058a6..79b55a7bb8 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,10 +1,8 @@ package io.stargate.sgv2.jsonapi.api.model.command.impl; -import static io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider.type; import static io.stargate.sgv2.jsonapi.config.constants.DocumentConstants.Fields.*; import static io.stargate.sgv2.jsonapi.config.constants.ServiceDescConstants.MODEL_NAME; import static io.stargate.sgv2.jsonapi.config.constants.ServiceDescConstants.PROVIDER; -import static org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.*; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; @@ -17,11 +15,9 @@ import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.node.NumericNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import io.quarkus.runtime.annotations.RegisterForReflection; import io.stargate.sgv2.jsonapi.api.model.command.*; import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.FilterDefinition; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; -import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.config.constants.DocumentConstants; import io.stargate.sgv2.jsonapi.config.constants.RerankingConstants; import io.stargate.sgv2.jsonapi.metrics.CommandFeature; @@ -32,11 +28,10 @@ import jakarta.validation.constraints.Positive; import java.io.IOException; import java.util.List; -import java.util.Map; import javax.annotation.Nullable; -import org.eclipse.microprofile.openapi.OASFactory; 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 vector and lexical sorting, then reranks the results.") @JsonTypeName(CommandName.Names.FIND_AND_RERANK) @@ -77,17 +72,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.", - ref = HybridLimits.SchemaProvider.REF, - examples = { - "50", - """ - {"$vector": 50, "$lexical": 10} - """ - }) - HybridLimits hybridLimits, + @Schema(ref = HybridLimits.REF) HybridLimits hybridLimits, /** ---- */ @Schema( description = @@ -133,29 +118,40 @@ public record Options( // Collection creation uses the same service record but also accepts authentication and // parameters. - @Schema(name = RerankOverrideSchema.NAME) - @RegisterForReflection - public static class RerankOverrideSchema implements ApiSchemaProvider { + @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) + }, + 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 = "#/components/schemas/" + NAME; - - @Override - public org.eclipse.microprofile.openapi.models.media.Schema schema() { - return type(OBJECT, NULL) - .addProperty(PROVIDER, type(STRING)) - .addProperty(MODEL_NAME, type(STRING)) - .required(List.of(PROVIDER, MODEL_NAME)) - .additionalPropertiesBoolean(false) - .addExample(Map.of(PROVIDER, "nvidia", MODEL_NAME, "nvidia/llama-3.2-nv-rerankqa-1b-v2")); - } } @JsonDeserialize(using = HybridLimitsDeserializer.class) + // Keep the scanner from inferring an object type for this union. @Schema( + name = HybridLimits.NAME, description = "Candidate read limits as one integer for both legs, or an object containing both $vector and $lexical integer limits. Accepted bounds are determined by server configuration.", - implementation = HybridLimits.SchemaProvider.class, - ref = HybridLimits.SchemaProvider.REF) + implementation = Void.class, + anyOf = {Integer.class, HybridLimitValuesSchema.class}, + examples = { + "50", + """ + {"$vector": 50, "$lexical": 10} + """ + }) public record HybridLimits( @JsonProperty(DocumentConstants.Fields.VECTOR_EMBEDDING_FIELD) int vectorLimit, /** ---- */ @@ -163,28 +159,8 @@ public record HybridLimits( @JsonIgnore CommandFeatures commandFeatures) implements Recordable { - @Schema(name = SchemaProvider.NAME) - @RegisterForReflection - public static class SchemaProvider implements ApiSchemaProvider { - public static final String NAME = "HybridLimits"; - public static final String REF = "#/components/schemas/" + NAME; - - @Override - public org.eclipse.microprofile.openapi.models.media.Schema schema() { - return OASFactory.createSchema() - .description(HybridLimits.class.getAnnotation(Schema.class).description()) - .addAnyOf(type(INTEGER)) - .addAnyOf( - type(OBJECT) - .addProperty(VECTOR_EMBEDDING_FIELD, type(INTEGER)) - .addProperty(LEXICAL_CONTENT_FIELD, type(INTEGER)) - .required(List.of(VECTOR_EMBEDDING_FIELD, LEXICAL_CONTENT_FIELD)) - .additionalPropertiesBoolean(false)) - .addAnyOf(type(NULL)) - .addExample(50) - .addExample(Map.of(VECTOR_EMBEDDING_FIELD, 50, LEXICAL_CONTENT_FIELD, 10)); - } - } + public static final String NAME = "HybridLimits"; + public static final String REF = "#/components/schemas/" + NAME; public static final HybridLimits DEFAULT = new HybridLimits( @@ -201,6 +177,18 @@ public DataRecorder recordTo(DataRecorder dataRecorder) { } } + @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 HybridLimitValuesSchema {} + /** Deserializer for the `hybridLimits` option, the limits for the inner reads. */ public static class HybridLimitsDeserializer extends StdDeserializer { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaFilter.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaFilter.java deleted file mode 100644 index 7789269663..0000000000 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaFilter.java +++ /dev/null @@ -1,76 +0,0 @@ -package io.stargate.sgv2.jsonapi.api.model.schema; - -import io.quarkus.smallrye.openapi.OpenApiFilter; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import org.eclipse.microprofile.openapi.OASFactory; -import org.eclipse.microprofile.openapi.OASFilter; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.models.OpenAPI; -import org.jboss.jandex.IndexView; - -/** Installs the clause schemas at build time, when Quarkus provides the application class index. */ -@OpenApiFilter(stages = OpenApiFilter.RunStage.BUILD) -public class ApiSchemaFilter implements OASFilter { - private final List> providers; - private final Set references; - - public ApiSchemaFilter(IndexView index) { - providers = - index.getAllKnownImplementations(ApiSchemaProvider.class).stream() - .>map( - info -> { - try { - return Class.forName( - info.name().toString(), - true, - Thread.currentThread().getContextClassLoader()) - .asSubclass(ApiSchemaProvider.class); - } catch (ClassNotFoundException e) { - throw new IllegalStateException( - "Cannot load API schema provider " + info.name(), e); - } - }) - .toList(); - references = - providers.stream() - .map(provider -> "#/components/schemas/" + schemaName(provider)) - .collect(Collectors.toSet()); - if (references.size() != providers.size()) { - throw new IllegalStateException("API schema providers must have unique schema names"); - } - } - - @Override - public org.eclipse.microprofile.openapi.models.media.Schema filterSchema( - org.eclipse.microprofile.openapi.models.media.Schema schema) { - if (references.contains(schema.getRef())) { - // The scanner infers an object from the Java class even when its wire schema allows other - // types. - schema.setType(null); - } - return schema; - } - - @Override - public void filterOpenAPI(OpenAPI openAPI) { - if (openAPI.getComponents() == null) { - openAPI.setComponents(OASFactory.createComponents()); - } - for (var provider : providers) { - openAPI - .getComponents() - .addSchema(schemaName(provider), ApiSchemaProvider.create(provider).schema()); - } - } - - private static String schemaName(Class provider) { - var annotation = provider.getAnnotation(Schema.class); - if (annotation == null || annotation.name().isBlank()) { - throw new IllegalStateException( - "API schema provider needs a schema name " + provider.getName()); - } - return annotation.name(); - } -} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java deleted file mode 100644 index fb80b01052..0000000000 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/schema/ApiSchemaProvider.java +++ /dev/null @@ -1,29 +0,0 @@ -package io.stargate.sgv2.jsonapi.api.model.schema; - -import java.util.List; -import org.eclipse.microprofile.openapi.OASFactory; -import org.eclipse.microprofile.openapi.models.media.Schema; - -/** - * A wire schema shared by OpenAPI and MCP. Link a provider from the owning type or field with - * {@code @Schema(implementation = ..., ref = ...)} and give the provider a unique - * {@code @Schema(name = ...)}. The reference prevents OpenAPI from also scanning the internal Java - * fields. Providers need a public no-argument constructor and {@code @RegisterForReflection} for - * native MCP use. - */ -public interface ApiSchemaProvider { - Schema schema(); - - static Schema type(Schema.SchemaType... types) { - return OASFactory.createSchema().type(List.of(types)); - } - - static ApiSchemaProvider create(Class providerClass) { - try { - return providerClass.getConstructor().newInstance(); - } catch (ReflectiveOperationException e) { - throw new IllegalStateException( - "Cannot create API schema provider " + providerClass.getName(), e); - } - } -} 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..e37ef805f0 --- /dev/null +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapter.java @@ -0,0 +1,149 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +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.Set; + +/** + * Shares Swagger's generated schemas with MCP while retaining the existing tool generator. + * + *

OpenAPI stores reusable schemas under components/schemas, while standalone JSON Schema uses + * $defs. Copy only the referenced definitions and adjust their addresses. Keeping references + * preserves validation constraints without expanding or merging schemas. + */ +@Decorator +@Priority(1) +public class McpOpenApiSchemaAdapter implements GlobalInputSchemaGenerator { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String OPEN_API_REF = "#/components/schemas/"; + private static final String DEFINITION_PREFIX = "_openapi_"; + private static final Set SCHEMA_MAPS = + Set.of("properties", "patternProperties", "$defs", "dependentSchemas"); + private static final Set SCHEMA_ARRAYS = Set.of("allOf", "anyOf", "oneOf", "prefixItems"); + private static final Set SCHEMA_VALUES = + Set.of( + "items", + "additionalProperties", + "unevaluatedProperties", + "contains", + "not", + "propertyNames", + "if", + "then", + "else", + "unevaluatedItems", + "contentSchema"); + + @Inject @Delegate GlobalInputSchemaGenerator delegate; + private JsonNode openApi; + + public McpOpenApiSchemaAdapter() {} + + McpOpenApiSchemaAdapter(JsonNode openApi) { + this.openApi = openApi; + } + + @Override + public InputSchema generate(ToolInfo tool) { + var generated = delegate.generate(tool); + try { + return new AdaptedInputSchema( + new JsonObject(adapt(MAPPER.readTree(generated.asJson())).toString())); + } catch (IOException e) { + throw new IllegalStateException("Cannot read the MCP input schema for " + tool.name(), e); + } + } + + ObjectNode adapt(JsonNode input) { + ObjectNode result = input.deepCopy(); + var definitions = MAPPER.createObjectNode(); + collectDefinitions(result, definitions); + if (!definitions.isEmpty()) { + var target = result.withObject("/$defs"); + definitions + .properties() + .forEach( + entry -> { + if (target.has(entry.getKey())) { + throw new IllegalStateException( + "Duplicate MCP schema definition " + entry.getKey()); + } + target.set(entry.getKey(), entry.getValue()); + }); + } + return result; + } + + private void collectDefinitions(JsonNode node, ObjectNode definitions) { + if (!(node instanceof ObjectNode schema)) { + return; + } + var reference = schema.path("$ref").asText(); + if (reference.startsWith(OPEN_API_REF)) { + var suffix = reference.substring(OPEN_API_REF.length()); + var component = suffix.split("/", 2)[0]; + var name = DEFINITION_PREFIX + component.replace("~1", "/").replace("~0", "~"); + var document = openApi(); + if (document.at(reference.substring(1)).isMissingNode()) { + throw new IllegalStateException("Unresolved OpenAPI schema reference " + reference); + } + schema.put("$ref", "#/$defs/" + DEFINITION_PREFIX + suffix); + if (!definitions.has(name)) { + var definition = document.at("/components/schemas/" + component).deepCopy(); + // Register before following dependencies so recursive definitions are copied only once. + definitions.set(name, definition); + collectDefinitions(definition, definitions); + } + } + // Examples, defaults, and property names can contain literal $ref values. Visit schemas only. + for (var keyword : SCHEMA_MAPS) { + if (schema.path(keyword).isObject()) { + schema.path(keyword).forEach(value -> collectDefinitions(value, definitions)); + } + } + for (var keyword : SCHEMA_ARRAYS) { + if (schema.path(keyword).isArray()) { + schema.path(keyword).forEach(value -> collectDefinitions(value, definitions)); + } + } + for (var keyword : SCHEMA_VALUES) { + collectDefinitions(schema.path(keyword), definitions); + } + } + + private synchronized JsonNode openApi() { + if (openApi == null) { + var loader = OpenApiConstants.classLoader; + if (loader == null) { + loader = Thread.currentThread().getContextClassLoader(); + } + // Quarkus packages the same build-time document for JVM and native applications. + try (var stream = loader.getResourceAsStream(OpenApiConstants.BASE_NAME + ".JSON")) { + if (stream == null) { + throw new IllegalStateException("The generated OpenAPI document is unavailable"); + } + openApi = MAPPER.readTree(stream); + } catch (IOException e) { + throw new IllegalStateException("Cannot read the generated OpenAPI document", e); + } + } + return openApi; + } + + private record AdaptedInputSchema(JsonObject value) implements InputSchema { + @Override + public String asJson() { + return value.encode(); + } + } +} diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java index 719870316a..e95afd0748 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java @@ -2,43 +2,28 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.victools.jsonschema.generator.CustomDefinition; import com.github.victools.jsonschema.generator.CustomPropertyDefinition; import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; import io.quarkiverse.mcp.server.runtime.SchemaGeneratorConfigCustomizer; -import io.smallrye.openapi.model.OpenApiVersion; -import io.smallrye.openapi.runtime.io.IOContext; -import io.smallrye.openapi.runtime.io.JsonIO; -import io.smallrye.openapi.runtime.io.media.SchemaIO; -import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import jakarta.inject.Singleton; -import java.util.HashMap; -import java.util.Map; import org.eclipse.microprofile.openapi.annotations.media.Schema; /** * Generates MCP schemas using Data API clause definitions and Jackson and OpenAPI annotations. - * Custom deserializers and delegating creators cannot be described by reflecting their fields. * - *

The generator's PLAIN_JSON preset does not read annotations. This customizer supplies field - * names, ignored fields, descriptions, and schema overrides. + *

MCP clients use these schemas to build tool arguments. Custom deserialization can accept JSON + * shapes that differ from the Java fields, so those types need overrides. Ordinary records still + * use field-based generation. + * + *

Named {@code @Schema} definitions refer to the schemas Quarkus already generates for Swagger. + * {@link McpOpenApiSchemaAdapter} packages those definitions into the complete MCP input schema. */ @Singleton public class McpSchemaCustomizer implements SchemaGeneratorConfigCustomizer { - private final SchemaIO schemaIO; - private final Map, ObjectNode> schemas = new HashMap<>(); - - public McpSchemaCustomizer() { - var context = - IOContext.forJson( - JsonIO.newInstance(null)); - // OpenAPI 3.1 uses the same JSON Schema dialect as the MCP generator. - context.setOpenApiVersion(OpenApiVersion.V3_1); - schemaIO = context.schemaIO(); - } + private static final ObjectMapper MAPPER = new ObjectMapper(); @Override public void customize(SchemaGeneratorConfigBuilder builder) { @@ -54,11 +39,13 @@ public void customize(SchemaGeneratorConfigBuilder builder) { .withIgnoreCheck( field -> { var ignore = field.getAnnotationConsideringFieldAndGetter(JsonIgnore.class); - return ignore != null && ignore.value(); + var schema = field.getAnnotationConsideringFieldAndGetter(Schema.class); + return (ignore != null && ignore.value()) || (schema != null && schema.hidden()); }) .withCustomDefinitionProvider( (field, context) -> { - var schema = schemaNode(field.getAnnotationConsideringFieldAndGetter(Schema.class)); + var schema = + schemaNode(field.getAnnotationConsideringFieldAndGetter(Schema.class), false); return schema == null ? null : new CustomPropertyDefinition(schema, CustomDefinition.AttributeInclusion.YES); @@ -81,7 +68,7 @@ public void customize(SchemaGeneratorConfigBuilder builder) { }) .withCustomDefinitionProvider( (javaType, context) -> { - var schema = schemaNode(javaType.getErasedType().getAnnotation(Schema.class)); + var schema = schemaNode(javaType.getErasedType().getAnnotation(Schema.class), true); return schema == null ? null : new CustomDefinition( @@ -91,19 +78,22 @@ public void customize(SchemaGeneratorConfigBuilder builder) { }); } - private synchronized ObjectNode schemaNode(Schema annotation) { - if (annotation == null - || !ApiSchemaProvider.class.isAssignableFrom(annotation.implementation())) { + private static ObjectNode schemaNode(Schema annotation, boolean typeAnnotation) { + if (annotation == null) { return null; } - var providerClass = annotation.implementation().asSubclass(ApiSchemaProvider.class); - // Serialize each provider once. Synchronization also protects the shared OpenAPI writer. - var schema = - schemas.computeIfAbsent( - providerClass, - type -> - (ObjectNode) schemaIO.write(ApiSchemaProvider.create(type).schema()).orElseThrow()); - // The generator adds field metadata, so each use needs its own mutable copy. - return schema.deepCopy(); + var reference = annotation.ref(); + if (reference.isEmpty()) { + var name = typeAnnotation ? annotation.name() : ""; + if (name.isEmpty()) { + var implementation = annotation.implementation().getAnnotation(Schema.class); + name = implementation == null ? "" : implementation.name(); + } + if (name.isEmpty()) { + return null; + } + reference = "#/components/schemas/" + name.replace("~", "~0").replace("/", "~1"); + } + return MAPPER.createObjectNode().put("$ref", reference); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index c039ed429a..4bb01ff47c 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -7,6 +7,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.node.MissingNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.victools.jsonschema.generator.*; import com.networknt.schema.JsonSchemaFactory; @@ -16,12 +18,12 @@ import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; import io.stargate.sgv2.jsonapi.TestConstants; +import io.stargate.sgv2.jsonapi.api.model.command.JsonDefinition; import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.FilterDefinition; import io.stargate.sgv2.jsonapi.api.model.command.clause.filter.SortDefinition; import io.stargate.sgv2.jsonapi.api.model.command.clause.sort.FindAndRerankSort; import io.stargate.sgv2.jsonapi.api.model.command.impl.CreateCollectionCommand; import io.stargate.sgv2.jsonapi.api.model.command.impl.FindAndRerankCommand; -import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; import io.stargate.sgv2.jsonapi.exception.RequestException; import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.service.provider.ApiModelSupport; @@ -129,8 +131,9 @@ void emptyAndWhitespacePropertyNamesMatchJackson() throws Exception { @MethodSource("clauseTypes") void everyAdvertisedClauseExampleDeserializes(Class clauseType) throws Exception { var schema = schemaFor(clauseType); - var examples = schema.get("examples"); - assertNotNull(examples, "Clause schema must advertise examples of the accepted JSON"); + var examples = schemaMetadata(schema, schema, "examples"); + assertFalse( + examples.isMissingNode(), "Clause schema must advertise examples of the accepted JSON"); assertFalse(examples.isEmpty()); for (var example : examples) { assertSchemaAccepts(schema, example); @@ -146,7 +149,7 @@ void everyAdvertisedClauseExampleDeserializes(Class clauseType) throws Except } static Stream> clauseTypes() throws Exception { - var loader = ApiSchemaProvider.class.getClassLoader(); + var loader = FilterDefinition.class.getClassLoader(); // Use the build's class index so new annotated clauses join these tests automatically. try (var indexStream = loader.getResourceAsStream("META-INF/jandex.idx")) { assertNotNull(indexStream, "The application class index must be available"); @@ -157,8 +160,12 @@ static Stream> clauseTypes() throws Exception { continue; } var type = Class.forName(annotation.target().asClass().name().toString(), false, loader); - var provider = type.getAnnotation(Schema.class).implementation(); - if (ApiSchemaProvider.class.isAssignableFrom(provider)) { + var schema = type.getAnnotation(Schema.class); + // Select parsed clause types, excluding helper classes for nested JSON shapes. + if (!schema.name().isBlank() + && schema.examples().length > 0 + && (JsonDefinition.class.isAssignableFrom(type) + || type.isAnnotationPresent(JsonDeserialize.class))) { clauseTypes.add(type); } } @@ -171,10 +178,40 @@ static Stream> clauseTypes() throws Exception { @ParameterizedTest @MethodSource("clauseTypes") void openApiAndMcpShareClauseSchemas(Class clauseType) throws Exception { - var provider = clauseType.getAnnotation(Schema.class).implementation(); - var name = provider.getAnnotation(Schema.class).name(); - var openApiSchema = openApi().path("components").path("schemas").path(name); - assertEquals(objectMapper.readTree(schemaFor(clauseType).toString()), openApiSchema); + var name = clauseType.getAnnotation(Schema.class).name(); + var document = openApi(); + var openApiSchema = document.path("components").path("schemas").path(name); + assertFalse(openApiSchema.isMissingNode(), "Missing OpenAPI component " + name); + var expected = + new McpOpenApiSchemaAdapter(document) + .adapt(objectMapper.createObjectNode().put("$ref", "#/components/schemas/" + name)); + var actual = objectMapper.readTree(schemaFor(clauseType).toString()); + assertEquals(referencedSchema(expected, expected), referencedSchema(actual, actual)); + assertEquals(expected.path("$defs"), actual.path("$defs")); + } + + @Test + void filterAnnotationDefinesBothSchemasWithoutWrapperFields() throws Exception { + var annotation = FilterDefinition.class.getAnnotation(Schema.class); + var schema = schemaFor(FilterDefinition.class); + var component = referencedSchema(schema, schema); + + assertEquals(annotation.description(), schemaMetadata(schema, schema, "description").asText()); + assertEquals(annotation.examples().length, component.path("examples").size()); + for (var i = 0; i < annotation.examples().length; i++) { + assertEquals( + objectMapper.readTree(annotation.examples()[i]), component.path("examples").get(i)); + } + assertTrue(component.path("additionalProperties").asBoolean()); + assertFalse(component.path("properties").has("json")); + assertFalse(component.path("properties").has("filterClause")); + assertSchemaAccepts(schema, objectMapper.readTree("null")); + assertSchemaAccepts(schema, objectMapper.readTree("{\"some.path\": {\"$gt\": 40}}")); + assertFalse( + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(schema) + .validate(objectMapper.readTree("[]")) + .isEmpty()); } /** A correct reusable schema has no effect unless command fields actually reference it. */ @@ -191,18 +228,24 @@ void openApiCommandsReferenceSharedSchemas() throws Exception { var options = referencedSchema(document, rerank.path("options")).path("properties"); assertEquals( schemas.path("HybridLimits"), referencedSchema(document, options.path("hybridLimits"))); - assertEquals( - schemas.path("RerankServiceOverride"), referencedSchema(document, options.path("rerank"))); - var mcpOverride = - inputSchema("findAndRerank") - .path("properties") - .path("options") - .path("properties") - .path("rerank") - .deepCopy(); + var openApiOverride = + (ObjectNode) referencedSchema(document, options.path("rerank")).deepCopy(); // The field description belongs to the command options, not the reusable schema component. + openApiOverride.remove("description"); + assertEquals(schemas.path("RerankServiceOverride"), openApiOverride); + var mcpDocument = inputSchema("findAndRerank"); + var mcpOverride = + (ObjectNode) + referencedSchema(mcpDocument, propertySchema(mcpDocument, "options", "rerank")) + .deepCopy(); ((ObjectNode) mcpOverride).remove("description"); - assertEquals(mcpOverride, schemas.path("RerankServiceOverride")); + var expected = + new McpOpenApiSchemaAdapter(document) + .adapt( + objectMapper + .createObjectNode() + .put("$ref", "#/components/schemas/RerankServiceOverride")); + assertEquals(referencedSchema(expected, expected), mcpOverride); } /** Check the complete command schema because inferred parent types can restrict valid clauses. */ @@ -244,6 +287,31 @@ private JsonNode referencedSchema(JsonNode document, JsonNode schema) { return schema; } + private JsonNode propertySchema(JsonNode document, String... names) { + var schema = document; + for (var name : names) { + schema = referencedSchema(document, schema).path("properties").path(name); + assertFalse(schema.isMissingNode(), "Missing property " + name); + } + return schema; + } + + private JsonNode schemaMetadata(JsonNode document, JsonNode schema, String keyword) { + if (schema.has(keyword)) { + return schema.get(keyword); + } + if (schema.has("$ref")) { + return schemaMetadata(document, referencedSchema(document, schema), keyword); + } + for (var branch : schema.path("allOf")) { + var metadata = schemaMetadata(document, branch, keyword); + if (!metadata.isMissingNode()) { + return metadata; + } + } + return MissingNode.getInstance(); + } + private JsonNode openApi() throws Exception { var json = given() @@ -260,9 +328,25 @@ private JsonNode openApi() throws Exception { void rerankToolUsesSortClassDescription() throws Exception { var description = FindAndRerankSort.class.getAnnotation(Schema.class).description(); assertFalse(description.isBlank()); + var document = inputSchema("findAndRerank"); assertEquals( description, - inputSchema("findAndRerank").path("properties").path("sort").path("description").asText()); + schemaMetadata(document, propertySchema(document, "sort"), "description").asText()); + } + + @Test + void rerankToolUsesOverrideFieldDescription() throws Exception { + var description = + FindAndRerankCommand.Options.class + .getDeclaredField("rerankServiceOverride") + .getAnnotation(Schema.class) + .description(); + assertFalse(description.isBlank()); + var document = inputSchema("findAndRerank"); + assertEquals( + description, + schemaMetadata(document, propertySchema(document, "options", "rerank"), "description") + .asText()); } /** @@ -272,17 +356,14 @@ void rerankToolUsesSortClassDescription() throws Exception { @MethodSource("rerankOverrideRequiredFields") void rerankOverrideSchemaMatchesRequiredFieldValidation(String json, boolean accepted) throws Exception { - var schema = - inputSchema("findAndRerank") - .path("properties") - .path("options") - .path("properties") - .path("rerank"); + var schema = inputSchema("findAndRerank"); var payload = objectMapper.readTree(json); + var command = toolInput("{}"); + command.putObject("options").set("rerank", payload); var errors = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) .getSchema(schema) - .validate(payload); + .validate(command); assertEquals( accepted, errors.isEmpty(), () -> "Schema validation for " + json + " returned " + errors); @@ -371,17 +452,44 @@ void rerankToolAcceptsWirePayloads(String limits) throws Exception { assertSchemaAccepts(inputSchema("findAndRerank"), payload); } + @ParameterizedTest + @ValueSource( + strings = { + "{\"$hybrid\":{\"$vector\":null,\"$lexical\":\"query\"}}", + "{\"$hybrid\":{\"$vectorize\":null,\"$lexical\":\"query\"}}", + "{\"$hybrid\":{\"$vectorize\":\"query\",\"$lexical\":null}}", + "{\"$hybrid\":{\"$vector\":[1,2,3],\"$lexical\":null}}", + "{\"$hybrid\":{\"$vector\":null}}", + "{\"$hybrid\":{\"$vectorize\":null}}", + "{\"$hybrid\":{\"$lexical\":null}}", + "{\"$hybrid\":{}}", + "{}", + "null" + }) + void sortSchemaAcceptsNullableAndEmptyWireForms(String json) throws Exception { + var sort = objectMapper.readTree(json); + assertSchemaAccepts(schemaFor(FindAndRerankSort.class), sort); + var clause = assertDoesNotThrow(() -> objectMapper.treeToValue(sort, FindAndRerankSort.class)); + if (!sort.isNull()) { + assertNotNull(clause); + } + + var payload = toolInput("{}"); + payload.set("sort", sort); + assertSchemaAccepts(inputSchema("findAndRerank"), payload); + } + @Test void rerankOverrideExamplesDeserialize() throws Exception { - var rerankSchema = - inputSchema("findAndRerank") - .path("properties") - .path("options") - .path("properties") - .path("rerank"); - assertFalse(rerankSchema.path("examples").isEmpty()); - for (var example : rerankSchema.path("examples")) { - assertSchemaAccepts(rerankSchema, example); + var document = inputSchema("findAndRerank"); + var rerank = propertySchema(document, "options", "rerank"); + var examples = schemaMetadata(document, rerank, "examples"); + assertFalse(examples.isMissingNode()); + assertFalse(examples.isEmpty()); + for (var example : examples) { + var command = toolInput("{}"); + command.putObject("options").set("rerank", example); + assertSchemaAccepts(document, command); assertNotNull( objectMapper.treeToValue( example, CreateCollectionCommand.Options.RerankServiceDesc.class)); @@ -470,11 +578,9 @@ static Stream invalidWireShapes() { } @Test - void openApiLimitExamplesAreValidClauseJson() throws Exception { - var schema = - FindAndRerankCommand.Options.class - .getDeclaredField("hybridLimits") - .getAnnotation(Schema.class); + void hybridLimitAnnotationExamplesAreValidClauseJson() throws Exception { + var schema = FindAndRerankCommand.HybridLimits.class.getAnnotation(Schema.class); + assertTrue(schema.examples().length > 0); for (var example : schema.examples()) { assertNotNull(objectMapper.readValue(example, FindAndRerankCommand.HybridLimits.class)); } @@ -508,6 +614,20 @@ void collectionCreationAcceptsRerankAuthenticationAndParameters() throws Excepti objectMapper.treeToValue(payload, CreateCollectionCommand.Options.RerankServiceDesc.class)); } + @Test + void allRegisteredToolsGenerateInputSchemas() { + var count = 0; + // Exercise named OpenAPI lookups for every tool, including ordinary options records. + for (var tool : toolManager) { + var schema = + assertDoesNotThrow( + () -> objectMapper.readTree(schemaGenerator.generate(tool).asJson()), tool.name()); + assertTrue(schema.path("properties").isObject(), tool.name()); + count++; + } + assertTrue(count > 0, "The MCP server must register tools"); + } + private JsonNode inputSchema(String name) throws Exception { // The registered tool includes nested command fields and the application's customizer wiring. var tool = toolManager.getTool(name); @@ -521,6 +641,7 @@ private JsonNode schemaFor(Class type) { objectMapper, SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) .without(Option.SCHEMA_VERSION_INDICATOR); new McpSchemaCustomizer().customize(builder); - return new SchemaGenerator(builder.build()).generateSchema(type); + return new McpOpenApiSchemaAdapter() + .adapt(new SchemaGenerator(builder.build()).generateSchema(type)); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java new file mode 100644 index 0000000000..7e11c5e8f2 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java @@ -0,0 +1,259 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import org.junit.jupiter.api.Test; + +/** Checks that standalone MCP schemas retain OpenAPI references and validation semantics. */ +class McpOpenApiSchemaAdapterTest { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void shouldCopyOnlyReachableComponentsAndRebaseTheirReferences() throws Exception { + var schema = adapt("{\"$ref\":\"#/components/schemas/ObjectValue\"}"); + + assertEquals("#/$defs/_openapi_ObjectValue", schema.path("$ref").asText()); + var definitions = schema.path("$defs"); + assertEquals(2, definitions.size()); + assertTrue(definitions.has("_openapi_ObjectValue")); + assertTrue(definitions.has("_openapi_Value")); + assertFalse(definitions.has("_openapi_Unused")); + assertEquals( + "#/$defs/_openapi_Value", + definitions + .path("_openapi_ObjectValue") + .path("properties") + .path("a") + .path("$ref") + .asText()); + assertAccepts(schema, "{\"a\":10}"); + assertRejects(schema, "{\"a\":15}"); + } + + @Test + void shouldLeaveFallbackSchemasAndExistingDefinitionsUnchanged() throws Exception { + var input = + MAPPER.readTree( + """ + { + "type":"object", + "properties":{"value":{"$ref":"#/$defs/Local"}}, + "$defs":{"Local":{"type":"string"}} + } + """); + + var adapted = new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(input); + + assertEquals(input, adapted); + assertAccepts(adapted, "{\"value\":\"text\"}"); + assertRejects(adapted, "{\"value\":5}"); + } + + @Test + void shouldRetainExistingDefinitionsWhenAddingOpenApiComponents() throws Exception { + var schema = + adapt( + """ + { + "type":"object", + "properties":{ + "local":{"$ref":"#/$defs/Local"}, + "remote":{"$ref":"#/components/schemas/Value"} + }, + "$defs":{"Local":{"type":"string"}} + } + """); + + assertEquals("#/$defs/Local", schema.path("properties").path("local").path("$ref").asText()); + assertEquals("string", schema.path("$defs").path("Local").path("type").asText()); + assertTrue(schema.path("$defs").has("_openapi_Value")); + assertAccepts(schema, "{\"local\":\"text\",\"remote\":10}"); + assertRejects(schema, "{\"local\":\"text\",\"remote\":15}"); + } + + @Test + void shouldSupportRecursiveComponentsWithoutExpandingThem() throws Exception { + var schema = adapt("{\"$ref\":\"#/components/schemas/Recursive\"}"); + + assertEquals(1, schema.path("$defs").size()); + assertEquals( + "#/$defs/_openapi_Recursive", + schema + .path("$defs") + .path("_openapi_Recursive") + .path("properties") + .path("next") + .path("anyOf") + .get(0) + .path("$ref") + .asText()); + assertAccepts(schema, "{\"value\":1,\"next\":{\"value\":2,\"next\":null}}"); + assertRejects(schema, "{\"value\":1,\"next\":{\"value\":\"wrong\"}}"); + } + + @Test + void shouldPreserveEscapedComponentNamesAndNestedPointerSuffixes() throws Exception { + var document = fixtureDocument(); + ((ObjectNode) document.path("components").path("schemas")) + .putObject("A/B~C") + .put("type", "object") + .putObject("properties") + .putObject("value") + .put("type", "integer"); + var input = + MAPPER.readTree( + """ + { + "type":"object", + "properties":{ + "whole":{"$ref":"#/components/schemas/A~1B~0C"}, + "value":{"$ref":"#/components/schemas/A~1B~0C/properties/value"} + } + } + """); + + var schema = new McpOpenApiSchemaAdapter(document).adapt(input); + + assertTrue(schema.path("$defs").has("_openapi_A/B~C")); + assertEquals( + "#/$defs/_openapi_A~1B~0C", schema.path("properties").path("whole").path("$ref").asText()); + assertEquals( + "#/$defs/_openapi_A~1B~0C/properties/value", + schema.path("properties").path("value").path("$ref").asText()); + assertAccepts(schema, "{\"whole\":{\"value\":1},\"value\":2}"); + assertRejects(schema, "{\"whole\":{\"value\":1},\"value\":\"wrong\"}"); + } + + @Test + void shouldRetainOverlappingConstraintsOnReferenceSiblings() throws Exception { + var schema = adapt("{\"$ref\":\"#/components/schemas/Value\",\"maximum\":20}"); + + assertEquals(20, schema.path("maximum").asInt()); + assertAccepts(schema, "10"); + assertRejects(schema, "15"); + } + + @Test + void shouldRetainDisjointConstraintsOnReferenceSiblings() throws Exception { + var schema = + adapt("{\"$ref\":\"#/components/schemas/ObjectValue\",\"additionalProperties\":false}"); + + assertTrue(schema.has("$ref")); + assertFalse(schema.path("additionalProperties").asBoolean()); + assertAccepts(schema, "{}"); + assertRejects(schema, "{\"a\":1}"); + } + + @Test + void shouldKeepReferenceSiblingsInsideCopiedComponents() throws Exception { + var document = fixtureDocument(); + ((ObjectNode) + document + .path("components") + .path("schemas") + .path("ObjectValue") + .path("properties") + .path("a")) + .put("maximum", 20); + var schema = + new McpOpenApiSchemaAdapter(document) + .adapt(MAPPER.readTree("{\"$ref\":\"#/components/schemas/ObjectValue\"}")); + + assertAccepts(schema, "{\"a\":10}"); + assertRejects(schema, "{\"a\":15}"); + } + + @Test + void shouldPreserveAPropertyNamedRefAndLiteralReferencesInInstanceData() throws Exception { + var input = + MAPPER.readTree( + """ + { + "type":"object", + "properties":{"$ref":{"$ref":"#/components/schemas/Value"}}, + "examples":[{"$ref":"#/components/schemas/Unknown"}], + "default":{"$ref":"#/components/schemas/Unknown"}, + "const":{"$ref":"#/components/schemas/Unknown"}, + "enum":[{"$ref":"#/components/schemas/Unknown"}] + } + """); + var schema = new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(input); + + assertEquals( + "#/$defs/_openapi_Value", schema.path("properties").path("$ref").path("$ref").asText()); + for (var keyword : new String[] {"examples", "default", "const", "enum"}) { + assertEquals(input.path(keyword), schema.path(keyword)); + } + } + + @Test + void shouldNotMutateInputsOrShareMutableComponentCopies() throws Exception { + var document = fixtureDocument(); + var originalDocument = document.deepCopy(); + var input = MAPPER.readTree("{\"$ref\":\"#/components/schemas/Value\"}"); + var originalInput = input.deepCopy(); + var adapter = new McpOpenApiSchemaAdapter(document); + var schema = adapter.adapt(input); + + ((ObjectNode) schema.path("$defs").path("_openapi_Value")).put("maximum", 100); + + assertEquals(originalDocument, document); + assertEquals(originalInput, input); + assertRejects(adapter.adapt(input), "15"); + } + + @Test + void shouldRejectMissingComponentReferences() throws Exception { + var adapter = new McpOpenApiSchemaAdapter(fixtureDocument()); + var input = MAPPER.readTree("{\"$ref\":\"#/components/schemas/Missing\"}"); + + assertThrows(IllegalStateException.class, () -> adapter.adapt(input)); + } + + private static ObjectNode adapt(String input) throws Exception { + return new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(MAPPER.readTree(input)); + } + + private static void assertAccepts(JsonNode schema, String input) throws Exception { + var errors = + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(schema) + .validate(MAPPER.readTree(input)); + assertTrue(errors.isEmpty(), errors::toString); + } + + private static void assertRejects(JsonNode schema, String input) throws Exception { + var errors = + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(schema) + .validate(MAPPER.readTree(input)); + assertFalse(errors.isEmpty()); + } + + private static JsonNode fixtureDocument() throws Exception { + return MAPPER.readTree( + """ + { + "components":{"schemas":{ + "Value":{"type":"integer","maximum":10}, + "ObjectValue":{"type":"object","properties":{"a":{"$ref":"#/components/schemas/Value"}}}, + "Recursive":{ + "type":"object", + "properties":{ + "value":{"type":"integer"}, + "next":{"anyOf":[{"$ref":"#/components/schemas/Recursive"},{"type":"null"}]} + }, + "required":["value"], + "additionalProperties":false + }, + "Unused":{"type":"string"} + }} + } + """); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java index 5d3c96c987..8acb130f30 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java @@ -3,20 +3,17 @@ import static org.junit.jupiter.api.Assertions.*; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.MissingNode; import com.github.victools.jsonschema.generator.Option; import com.github.victools.jsonschema.generator.OptionPreset; import com.github.victools.jsonschema.generator.SchemaGenerator; import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder; import com.github.victools.jsonschema.generator.SchemaVersion; -import io.stargate.sgv2.jsonapi.api.model.schema.ApiSchemaProvider; -import java.util.concurrent.atomic.AtomicInteger; import org.eclipse.microprofile.openapi.annotations.media.Schema; import org.junit.jupiter.api.Test; /** - * Checks description resolution and reuse of independent schema copies in {@link - * McpSchemaCustomizer}. + * Checks OpenAPI references and annotation descriptions supplied by {@link McpSchemaCustomizer}. */ class McpSchemaCustomizerTest { @@ -46,73 +43,72 @@ record TestDescribedType(String value) {} record TestTypeDescription(TestDescribedType value) {} - @Schema(implementation = CountingSchemaProvider.class) - record TestCachedType(String value) {} + record TestHiddenFields(@Schema(hidden = true) String internal, String visible) {} - record TestCachedFields( - @Schema(implementation = CountingSchemaProvider.class, description = "First field") - TestCachedType first, - @Schema(implementation = CountingSchemaProvider.class, description = "Second field") - TestCachedType second) {} + record TestNamedField(@Schema(name = "aPropertyName") String value) {} @Schema(name = "CustomizerTestSchema") - public static class CountingSchemaProvider implements ApiSchemaProvider { - static final AtomicInteger instances = new AtomicInteger(); - static final AtomicInteger schemasBuilt = new AtomicInteger(); + record TestSharedType(String internalValue) {} - public CountingSchemaProvider() { - instances.incrementAndGet(); - } + @Schema(ref = "#/components/schemas/CustomizerTestSchema") + record TestReferencedType(String internalValue) {} - @Override - public org.eclipse.microprofile.openapi.models.media.Schema schema() { - schemasBuilt.incrementAndGet(); - return ApiSchemaProvider.type( - org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.OBJECT) - .addProperty( - "value", - ApiSchemaProvider.type( - org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.STRING)); - } + @Schema(name = "CustomizerTestSchema") + static class SharedSchema {} + + record TestImplementationValue(String internalValue) {} + + record TestSharedFields( + @Schema(implementation = SharedSchema.class, description = "First field") + TestImplementationValue first, + @Schema(implementation = SharedSchema.class, description = "Second field") + TestImplementationValue second) {} + + @Test + void shouldReferenceNamedComponentInsteadOfInternalJavaFields() { + var schema = schemaFor(TestSharedType.class); + + assertEquals("#/components/schemas/CustomizerTestSchema", schema.path("$ref").asText()); + assertFalse(schema.path("properties").has("internalValue")); } @Test - void shouldCacheSchemaAcrossTypesAndFields() { - CountingSchemaProvider.instances.set(0); - CountingSchemaProvider.schemasBuilt.set(0); - var customizer = new McpSchemaCustomizer(); + void shouldKeepExplicitComponentReference() { + assertEquals(schemaFor(TestSharedType.class), schemaFor(TestReferencedType.class)); + } - schemaFor(customizer, TestCachedType.class); - schemaFor(customizer, TestCachedFields.class); - schemaFor(customizer, TestCachedType.class); + @Test + void shouldReferenceFieldImplementationAndKeepFieldDescriptions() { + var properties = schemaFor(TestSharedFields.class).path("properties"); - assertEquals(1, CountingSchemaProvider.instances.get()); - assertEquals(1, CountingSchemaProvider.schemasBuilt.get()); + for (var name : new String[] {"first", "second"}) { + assertEquals( + "#/components/schemas/CustomizerTestSchema", properties.path(name).path("$ref").asText()); + assertFalse(properties.path(name).path("properties").has("internalValue")); + } + assertEquals("First field", schemaValue(properties.path("first"), "description").asText()); + assertEquals("Second field", schemaValue(properties.path("second"), "description").asText()); + assertFalse(schemaFor(TestSharedType.class).has("description")); } @Test - void shouldIsolateCachedSchemaFromGeneratedNodes() { - var customizer = new McpSchemaCustomizer(); - var schema = schemaFor(customizer, TestCachedType.class); - var original = schema.deepCopy(); - ((ObjectNode) schema.path("properties").path("value")).put("type", "integer"); + void shouldOmitFieldsHiddenBySchemaAnnotation() { + var properties = schemaFor(TestHiddenFields.class).path("properties"); - assertEquals(original, schemaFor(customizer, TestCachedType.class)); + assertFalse(properties.has("internal")); + assertTrue(properties.has("visible")); } @Test - void shouldKeepFieldDescriptionsIndependentWhenSharingCachedSchema() { - var customizer = new McpSchemaCustomizer(); - var properties = schemaFor(customizer, TestCachedFields.class).path("properties"); + void shouldNotTreatFieldSchemaNameAsComponentName() { + var schema = assertDoesNotThrow(() -> schemaFor(TestNamedField.class)); - assertEquals("First field", properties.path("first").path("description").asText()); - assertEquals("Second field", properties.path("second").path("description").asText()); - assertFalse(schemaFor(customizer, TestCachedType.class).has("description")); + assertEquals("string", schema.path("properties").path("value").path("type").asText()); } - private static JsonNode schemaFor(McpSchemaCustomizer customizer, Class type) { + private static JsonNode schemaFor(Class type) { var builder = baseConfigBuilder(); - customizer.customize(builder); + new McpSchemaCustomizer().customize(builder); return new SchemaGenerator(builder.build()).generateSchema(type); } @@ -145,31 +141,50 @@ void shouldIncludeSchemaDescriptionWithCustomizer() { new McpSchemaCustomizer().customize(configBuilder); var schema = new SchemaGenerator(configBuilder.build()).generateSchema(TestCreateTable.class); - var props = schema.get("properties"); + var props = schemaValue(schema, "properties"); // Level 1: top-level fields - assertEquals("Table definition", props.get("definition").get("description").asText()); - assertEquals("Configuration options", props.get("options").get("description").asText()); - assertNull( - props.get("noAnnotation").get("description"), + assertEquals("Table definition", schemaValue(props.path("definition"), "description").asText()); + assertEquals( + "Configuration options", schemaValue(props.path("options"), "description").asText()); + assertTrue( + schemaValue(props.path("noAnnotation"), "description").isMissingNode(), "Fields without @Schema should have no description"); // Level 2: nested inside TestDefinition - var defProps = props.get("definition").get("properties"); + var defProps = schemaValue(props.path("definition"), "properties"); assertEquals( - "Column definitions for the table", defProps.get("columns").get("description").asText()); - assertEquals("Primary key definition", defProps.get("primaryKey").get("description").asText()); + "Column definitions for the table", + schemaValue(defProps.path("columns"), "description").asText()); + assertEquals( + "Primary key definition", schemaValue(defProps.path("primaryKey"), "description").asText()); // Level 2: nested inside TestOptions - var optProps = props.get("options").get("properties"); + var optProps = schemaValue(props.path("options"), "properties"); assertEquals( "Flag to ignore if already exists", - optProps.get("ifNotExists").get("description").asText()); + schemaValue(optProps.path("ifNotExists"), "description").asText()); // Level 3: nested inside TestPrimaryKey - var pkProps = defProps.get("primaryKey").get("properties"); - assertEquals("Partition key columns", pkProps.get("keys").get("description").asText()); - assertEquals("Clustering key ordering", pkProps.get("ordering").get("description").asText()); + var pkProps = schemaValue(defProps.path("primaryKey"), "properties"); + assertEquals( + "Partition key columns", schemaValue(pkProps.path("keys"), "description").asText()); + assertEquals( + "Clustering key ordering", schemaValue(pkProps.path("ordering"), "description").asText()); + } + + /** Metadata may accompany a schema in a separate allOf branch without merging its constraints. */ + private static JsonNode schemaValue(JsonNode schema, String keyword) { + if (schema.has(keyword)) { + return schema.get(keyword); + } + for (var branch : schema.path("allOf")) { + var value = schemaValue(branch, keyword); + if (!value.isMissingNode()) { + return value; + } + } + return MissingNode.getInstance(); } @Test From ff0f1454463d2ee0ed64161174fc8119b4498fe4 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 20 Oct 2026 16:42:35 -0700 Subject: [PATCH 21/35] Simplify MCP schema annotation handling --- .../api/v1/mcp/McpSchemaCustomizer.java | 10 +-- .../api/v1/mcp/McpSchemaCustomizerTest.java | 62 ++++++------------- 2 files changed, 22 insertions(+), 50 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java index e95afd0748..46cadcfdda 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import com.github.victools.jsonschema.generator.CustomDefinition; import com.github.victools.jsonschema.generator.CustomPropertyDefinition; @@ -23,8 +23,6 @@ */ @Singleton public class McpSchemaCustomizer implements SchemaGeneratorConfigCustomizer { - private static final ObjectMapper MAPPER = new ObjectMapper(); - @Override public void customize(SchemaGeneratorConfigBuilder builder) { builder @@ -85,15 +83,11 @@ private static ObjectNode schemaNode(Schema annotation, boolean typeAnnotation) var reference = annotation.ref(); if (reference.isEmpty()) { var name = typeAnnotation ? annotation.name() : ""; - if (name.isEmpty()) { - var implementation = annotation.implementation().getAnnotation(Schema.class); - name = implementation == null ? "" : implementation.name(); - } if (name.isEmpty()) { return null; } reference = "#/components/schemas/" + name.replace("~", "~0").replace("/", "~1"); } - return MAPPER.createObjectNode().put("$ref", reference); + return JsonNodeFactory.instance.objectNode().put("$ref", reference); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java index 8acb130f30..9ec348f1f1 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizerTest.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.*; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.MissingNode; import com.github.victools.jsonschema.generator.Option; import com.github.victools.jsonschema.generator.OptionPreset; import com.github.victools.jsonschema.generator.SchemaGenerator; @@ -53,16 +52,13 @@ record TestSharedType(String internalValue) {} @Schema(ref = "#/components/schemas/CustomizerTestSchema") record TestReferencedType(String internalValue) {} - @Schema(name = "CustomizerTestSchema") - static class SharedSchema {} - - record TestImplementationValue(String internalValue) {} + record TestFieldValue(String internalValue) {} record TestSharedFields( - @Schema(implementation = SharedSchema.class, description = "First field") - TestImplementationValue first, - @Schema(implementation = SharedSchema.class, description = "Second field") - TestImplementationValue second) {} + @Schema(ref = "#/components/schemas/CustomizerTestSchema", description = "First field") + TestFieldValue first, + @Schema(ref = "#/components/schemas/CustomizerTestSchema", description = "Second field") + TestFieldValue second) {} @Test void shouldReferenceNamedComponentInsteadOfInternalJavaFields() { @@ -78,7 +74,7 @@ void shouldKeepExplicitComponentReference() { } @Test - void shouldReferenceFieldImplementationAndKeepFieldDescriptions() { + void shouldKeepFieldReferencesAndDescriptions() { var properties = schemaFor(TestSharedFields.class).path("properties"); for (var name : new String[] {"first", "second"}) { @@ -86,8 +82,8 @@ void shouldReferenceFieldImplementationAndKeepFieldDescriptions() { "#/components/schemas/CustomizerTestSchema", properties.path(name).path("$ref").asText()); assertFalse(properties.path(name).path("properties").has("internalValue")); } - assertEquals("First field", schemaValue(properties.path("first"), "description").asText()); - assertEquals("Second field", schemaValue(properties.path("second"), "description").asText()); + assertEquals("First field", properties.path("first").path("description").asText()); + assertEquals("Second field", properties.path("second").path("description").asText()); assertFalse(schemaFor(TestSharedType.class).has("description")); } @@ -141,50 +137,32 @@ void shouldIncludeSchemaDescriptionWithCustomizer() { new McpSchemaCustomizer().customize(configBuilder); var schema = new SchemaGenerator(configBuilder.build()).generateSchema(TestCreateTable.class); - var props = schemaValue(schema, "properties"); + var props = schema.path("properties"); // Level 1: top-level fields - assertEquals("Table definition", schemaValue(props.path("definition"), "description").asText()); - assertEquals( - "Configuration options", schemaValue(props.path("options"), "description").asText()); + assertEquals("Table definition", props.path("definition").path("description").asText()); + assertEquals("Configuration options", props.path("options").path("description").asText()); assertTrue( - schemaValue(props.path("noAnnotation"), "description").isMissingNode(), + props.path("noAnnotation").path("description").isMissingNode(), "Fields without @Schema should have no description"); // Level 2: nested inside TestDefinition - var defProps = schemaValue(props.path("definition"), "properties"); + var defProps = props.path("definition").path("properties"); assertEquals( - "Column definitions for the table", - schemaValue(defProps.path("columns"), "description").asText()); + "Column definitions for the table", defProps.path("columns").path("description").asText()); assertEquals( - "Primary key definition", schemaValue(defProps.path("primaryKey"), "description").asText()); + "Primary key definition", defProps.path("primaryKey").path("description").asText()); // Level 2: nested inside TestOptions - var optProps = schemaValue(props.path("options"), "properties"); + var optProps = props.path("options").path("properties"); assertEquals( "Flag to ignore if already exists", - schemaValue(optProps.path("ifNotExists"), "description").asText()); + optProps.path("ifNotExists").path("description").asText()); // Level 3: nested inside TestPrimaryKey - var pkProps = schemaValue(defProps.path("primaryKey"), "properties"); - assertEquals( - "Partition key columns", schemaValue(pkProps.path("keys"), "description").asText()); - assertEquals( - "Clustering key ordering", schemaValue(pkProps.path("ordering"), "description").asText()); - } - - /** Metadata may accompany a schema in a separate allOf branch without merging its constraints. */ - private static JsonNode schemaValue(JsonNode schema, String keyword) { - if (schema.has(keyword)) { - return schema.get(keyword); - } - for (var branch : schema.path("allOf")) { - var value = schemaValue(branch, keyword); - if (!value.isMissingNode()) { - return value; - } - } - return MissingNode.getInstance(); + var pkProps = defProps.path("primaryKey").path("properties"); + assertEquals("Partition key columns", pkProps.path("keys").path("description").asText()); + assertEquals("Clustering key ordering", pkProps.path("ordering").path("description").asText()); } @Test From d3e4ea7bc6e89cdb24051d6fd3454fed07be30c3 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 20 Oct 2026 18:36:18 -0700 Subject: [PATCH 22/35] Test packaged MCP schemas and fix argument-local references --- .../api/model/command/JsonDefinition.java | 2 +- .../clause/filter/FilterDefinition.java | 1 + .../clause/sort/FindAndRerankSort.java | 2 +- .../api/v1/mcp/McpOpenApiSchemaAdapter.java | 72 +++++--- .../api/v1/mcp/McpSchemaCustomizer.java | 1 + .../v1/mcp/McpOpenApiSchemaAdapterTest.java | 74 ++++++++ .../api/v1/mcp/McpSchemaIntegrationTest.java | 170 ++++++++++++++++++ 7 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaIntegrationTest.java 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 592c37981c..69fc011d6e 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 @@ -16,7 +16,7 @@ * @param Type of the value object to be deserialized from the JSON value */ public abstract class JsonDefinition { - /** The wrapped JSON value */ + /** The whole clause value. Clients do not send a nested json property. */ @Schema(hidden = true) private final JsonNode 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 838dc43de3..373344a3bd 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 @@ -16,6 +16,7 @@ @Schema( name = "FilterDefinition", type = SchemaType.OBJECT, + // FilterClause describes parsed state and would incorrectly reserve fields such as empty. implementation = Object.class, nullable = true, additionalProperties = Schema.True.class, 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 d1b9ce9d61..a9251c0efe 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 @@ -57,7 +57,7 @@ public record FindAndRerankSort( @JsonIgnore CommandFeatures commandFeatures) implements Recordable { - // Java annotations reference helper classes to describe nested schemas. + // These classes carry nested schema annotations and are never instantiated by the parser. @Schema( name = "HybridSort", type = SchemaType.OBJECT, 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 index e37ef805f0..ebf1a87fb4 100644 --- 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 @@ -13,6 +13,7 @@ import jakarta.inject.Inject; import java.io.IOException; import java.util.Set; +import java.util.function.Consumer; /** * Shares Swagger's generated schemas with MCP while retaining the existing tool generator. @@ -66,6 +67,7 @@ public InputSchema generate(ToolInfo tool) { ObjectNode adapt(JsonNode input) { ObjectNode result = input.deepCopy(); + rebaseArgumentReferences(result); var definitions = MAPPER.createObjectNode(); collectDefinitions(result, definitions); if (!definitions.isEmpty()) { @@ -84,40 +86,70 @@ ObjectNode adapt(JsonNode input) { return result; } + private static void rebaseArgumentReferences(ObjectNode input) { + // The fallback generator nests each argument's $defs but leaves its references root-relative. + // Keep argument-local definitions separate to avoid name collisions. + for (var argument : input.path("properties").properties()) { + var schema = argument.getValue(); + if (!schema.path("$defs").isObject()) { + continue; + } + var prefix = "#/properties/" + argument.getKey().replace("~", "~0").replace("/", "~1"); + visitSchemas( + schema, + nested -> { + var reference = nested.path("$ref").asText(); + if (reference.startsWith("#/$defs/") + && input.at(reference.substring(1)).isMissingNode() + && !schema.at(reference.substring(1)).isMissingNode()) { + nested.put("$ref", prefix + reference.substring(1)); + } + }); + } + } + private void collectDefinitions(JsonNode node, ObjectNode definitions) { + visitSchemas( + node, + schema -> { + var reference = schema.path("$ref").asText(); + if (reference.startsWith(OPEN_API_REF)) { + var suffix = reference.substring(OPEN_API_REF.length()); + var component = suffix.split("/", 2)[0]; + var name = DEFINITION_PREFIX + component.replace("~1", "/").replace("~0", "~"); + var document = openApi(); + if (document.at(reference.substring(1)).isMissingNode()) { + throw new IllegalStateException("Unresolved OpenAPI schema reference " + reference); + } + schema.put("$ref", "#/$defs/" + DEFINITION_PREFIX + suffix); + if (!definitions.has(name)) { + var definition = document.at("/components/schemas/" + component).deepCopy(); + // Register before recursion to handle cyclic references. + definitions.set(name, definition); + collectDefinitions(definition, definitions); + } + } + }); + } + + private static void visitSchemas(JsonNode node, Consumer visitor) { if (!(node instanceof ObjectNode schema)) { return; } - var reference = schema.path("$ref").asText(); - if (reference.startsWith(OPEN_API_REF)) { - var suffix = reference.substring(OPEN_API_REF.length()); - var component = suffix.split("/", 2)[0]; - var name = DEFINITION_PREFIX + component.replace("~1", "/").replace("~0", "~"); - var document = openApi(); - if (document.at(reference.substring(1)).isMissingNode()) { - throw new IllegalStateException("Unresolved OpenAPI schema reference " + reference); - } - schema.put("$ref", "#/$defs/" + DEFINITION_PREFIX + suffix); - if (!definitions.has(name)) { - var definition = document.at("/components/schemas/" + component).deepCopy(); - // Register before following dependencies so recursive definitions are copied only once. - definitions.set(name, definition); - collectDefinitions(definition, definitions); - } - } + visitor.accept(schema); // Examples, defaults, and property names can contain literal $ref values. Visit schemas only. for (var keyword : SCHEMA_MAPS) { if (schema.path(keyword).isObject()) { - schema.path(keyword).forEach(value -> collectDefinitions(value, definitions)); + schema.path(keyword).forEach(value -> visitSchemas(value, visitor)); } } for (var keyword : SCHEMA_ARRAYS) { if (schema.path(keyword).isArray()) { - schema.path(keyword).forEach(value -> collectDefinitions(value, definitions)); + schema.path(keyword).forEach(value -> visitSchemas(value, visitor)); } } for (var keyword : SCHEMA_VALUES) { - collectDefinitions(schema.path(keyword), definitions); + visitSchemas(schema.path(keyword), visitor); } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java index 46cadcfdda..195b4fb9c4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java @@ -82,6 +82,7 @@ private static ObjectNode schemaNode(Schema annotation, boolean typeAnnotation) } var reference = annotation.ref(); if (reference.isEmpty()) { + // A type name identifies an OpenAPI component. A field name only names that property. var name = typeAnnotation ? annotation.name() : ""; if (name.isEmpty()) { return null; diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java index 7e11c5e8f2..24a685dc9e 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java @@ -54,6 +54,80 @@ void shouldLeaveFallbackSchemasAndExistingDefinitionsUnchanged() throws Exceptio assertRejects(adapted, "{\"value\":5}"); } + @Test + void shouldResolveFallbackDefinitionsWithinEachToolArgument() throws Exception { + var input = + MAPPER.readTree( + """ + { + "type":"object", + "properties":{ + "options/~":{ + "type":"object", + "properties":{ + "value":{"$ref":"#/$defs/Local"}, + "shared":{"$ref":"#/components/schemas/Value"} + }, + "$defs":{"Local":{ + "type":"object", + "properties":{ + "name":{"type":"string"}, + "next":{"$ref":"#/$defs/Local"} + } + }}, + "examples":[{"value":{"$ref":"#/$defs/Local"}}] + }, + "other":{ + "type":"object", + "properties":{"value":{"$ref":"#/$defs/Local"}}, + "$defs":{"Local":{"type":"integer"}} + } + } + } + """); + var original = input.deepCopy(); + var schema = new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(input); + + assertEquals(original, input); + assertEquals( + "#/properties/options~1~0/$defs/Local", + schema.at("/properties/options~1~0/properties/value/$ref").asText()); + assertEquals( + original.at("/properties/options~1~0/examples"), + schema.at("/properties/options~1~0/examples")); + assertAccepts( + schema, + """ + {"options/~":{"value":{"name":"first","next":{"name":"second"}},"shared":10}, + "other":{"value":2}} + """); + assertRejects(schema, "{\"options/~\":{\"value\":{\"next\":{\"name\":2}}}}"); + assertRejects(schema, "{\"options/~\":{\"shared\":15}}"); + assertRejects(schema, "{\"other\":{\"value\":\"wrong\"}}"); + } + + @Test + void shouldPreserveValidRootReferencesWhenArgumentDefinitionsHaveTheSameName() throws Exception { + var input = + MAPPER.readTree( + """ + { + "type":"object", + "properties":{"options":{ + "type":"object", + "properties":{"value":{"$ref":"#/$defs/Local"}}, + "$defs":{"Local":{"type":"integer"}} + }}, + "$defs":{"Local":{"type":"string"}} + } + """); + var schema = new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(input); + + assertEquals(input, schema); + assertAccepts(schema, "{\"options\":{\"value\":\"text\"}}"); + assertRejects(schema, "{\"options\":{\"value\":5}}"); + } + @Test void shouldRetainExistingDefinitionsWhenAddingOpenApiComponents() throws Exception { var schema = diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaIntegrationTest.java new file mode 100644 index 0000000000..6ad33411b8 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaIntegrationTest.java @@ -0,0 +1,170 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import io.quarkiverse.mcp.server.test.McpAssured.ToolsPage; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.quarkus.test.junit.TestProfile; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import io.vertx.core.json.JsonObject; +import org.junit.jupiter.api.Test; + +/** Checks the schemas clients receive from the packaged application's tools/list endpoint. */ +@QuarkusIntegrationTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +class McpSchemaIntegrationTest extends McpIntegrationTestBase { + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final JsonSchemaFactory SCHEMAS = + JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); + + // Tool discovery loads the packaged OpenAPI document without executing database commands. + @Test + void advertisedToolSchemasResolveTheirReferences() { + mcpClient + .when() + .toolsList( + page -> { + assertFalse(page.tools().isEmpty()); + assertNull(page.nextCursor(), "Validate every advertised tool in this response"); + assertNotNull(page.findByName("find")); + assertNotNull(page.findByName("findAndRerank")); + for (var tool : page.tools()) { + assertEquals("object", tool.inputSchema().getString("type"), tool.name()); + assertDoesNotThrow( + () -> schemaFor(page, tool.name()).initializeValidators(), tool.name()); + } + }) + .thenAssertResults(); + } + + @Test + void findAdvertisesDocumentPathsInsteadOfJavaFields() { + mcpClient + .when() + .toolsList( + page -> { + var schema = schemaFor(page, "find"); + var arguments = + new JsonObject( + """ + { + "keyspace":"default_keyspace", + "collection":"schema_test", + "filter":{"empty":{"$eq":true},"json":"example"}, + "sort":{"age":-1} + } + """); + assertAccepts(schema, arguments); + assertRejects(schema, arguments.copy().put("filter", "not an object")); + assertRejects(schema, arguments.copy().put("sort", "not an object")); + }) + .thenAssertResults(); + } + + @Test + void createCollectionResolvesProviderOptionsDefinitions() { + mcpClient + .when() + .toolsList( + page -> { + var schema = schemaFor(page, "createCollection"); + var arguments = + new JsonObject( + """ + { + "keyspace":"default_keyspace", + "collection":"schema_test", + "options":{"rerank":{"service":{ + "provider":"nvidia", + "modelName":"nvidia/rerank-v1", + "authentication":{"providerKey":"credential-reference"}, + "parameters":{"truncate":"END"} + }}} + } + """); + assertAccepts(schema, arguments); + + var invalidAuthentication = arguments.copy(); + invalidAuthentication + .getJsonObject("options") + .getJsonObject("rerank") + .getJsonObject("service") + .put("authentication", "not an object"); + assertRejects(schema, invalidAuthentication); + }) + .thenAssertResults(); + } + + @Test + void findAndRerankPreservesNestedConstraints() { + mcpClient + .when() + .toolsList( + page -> { + var schema = schemaFor(page, "findAndRerank"); + var arguments = + new JsonObject( + """ + { + "keyspace":"default_keyspace", + "collection":"schema_test", + "filter":null, + "sort":{"$hybrid":{"$vector":[1,2,3],"$lexical":"coffee"}}, + "options":{"hybridLimits":{"$vector":50,"$lexical":10}} + } + """); + assertAccepts(schema, arguments); + + var binaryVector = arguments.copy(); + binaryVector + .getJsonObject("sort") + .getJsonObject("$hybrid") + .put("$vector", new JsonObject().put("$binary", "P4AAAEAAAABAQAAA")); + assertAccepts(schema, binaryVector); + + var shorthand = + arguments.copy().put("sort", new JsonObject().put("$hybrid", "coffee")); + shorthand.getJsonObject("options").put("hybridLimits", 50); + assertAccepts(schema, shorthand); + + var conflictingVector = arguments.copy(); + conflictingVector + .getJsonObject("sort") + .getJsonObject("$hybrid") + .put("$vectorize", "coffee"); + assertRejects(schema, conflictingVector); + + var missingLimit = arguments.copy(); + missingLimit + .getJsonObject("options") + .getJsonObject("hybridLimits") + .remove("$lexical"); + assertRejects(schema, missingLimit); + }) + .thenAssertResults(); + } + + private static JsonSchema schemaFor(ToolsPage page, String name) { + var tool = page.findByName(name); + assertNotNull(tool, name); + return SCHEMAS.getSchema(json(tool.inputSchema())); + } + + private static void assertAccepts(JsonSchema schema, JsonObject arguments) { + var errors = schema.validate(json(arguments)); + assertTrue(errors.isEmpty(), errors::toString); + } + + private static void assertRejects(JsonSchema schema, JsonObject arguments) { + assertFalse(schema.validate(json(arguments)).isEmpty()); + } + + private static JsonNode json(JsonObject value) { + return assertDoesNotThrow(() -> MAPPER.readTree(value.encode())); + } +} From 15875b7ef286b76058bbe6c21935064a249f236c Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 20 Oct 2026 18:48:25 -0700 Subject: [PATCH 23/35] Remove redundant additionalProperties schema annotations --- .../api/model/command/clause/filter/FilterDefinition.java | 1 - .../jsonapi/api/model/command/clause/filter/SortDefinition.java | 1 - .../sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java | 1 - 3 files changed, 3 deletions(-) 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 373344a3bd..7f382e7f96 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 @@ -19,7 +19,6 @@ // FilterClause describes parsed state and would incorrectly reserve fields such as empty. implementation = Object.class, nullable = true, - additionalProperties = Schema.True.class, description = "A Data API filter object mapping document paths to values or operator expressions, such as $eq, $gt, $in, $and, and $or.", examples = { 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 85347fc41c..f9a8139cde 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 @@ -21,7 +21,6 @@ type = SchemaType.OBJECT, implementation = Object.class, nullable = true, - additionalProperties = Schema.True.class, description = "A Data API sort object mapping paths to 1 (ascending) or -1 (descending). Collections also accept a single $vector, $vectorize, or $lexical sort. Tables use the vector or lexical column name.", examples = { diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 4bb01ff47c..1249c76a97 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -202,7 +202,6 @@ void filterAnnotationDefinesBothSchemasWithoutWrapperFields() throws Exception { assertEquals( objectMapper.readTree(annotation.examples()[i]), component.path("examples").get(i)); } - assertTrue(component.path("additionalProperties").asBoolean()); assertFalse(component.path("properties").has("json")); assertFalse(component.path("properties").has("filterClause")); assertSchemaAccepts(schema, objectMapper.readTree("null")); From 5fb06da01702eee857dc6521752a6b78178c6a5c Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 22 Oct 2026 10:30:31 -0700 Subject: [PATCH 24/35] Share MCP HTTP test support and run CI for stacked PRs --- .github/workflows/continuous-integration.yaml | 2 +- .../api/v1/mcp/McpHttpTestSupport.java | 59 +++++++++++++++++++ .../jsonapi/api/v1/mcp/McpProtocolTest.java | 46 ++++----------- 3 files changed, 70 insertions(+), 37 deletions(-) create mode 100644 src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpHttpTestSupport.java diff --git a/.github/workflows/continuous-integration.yaml b/.github/workflows/continuous-integration.yaml index 2a3541648c..db25a40c5f 100644 --- a/.github/workflows/continuous-integration.yaml +++ b/.github/workflows/continuous-integration.yaml @@ -9,7 +9,7 @@ on: branches: [ "main", "feature/*" ] pull_request: - branches: [ "main", "feature/*" ] + branches: [ "main", "feature/*", "fix/mcp-*" ] workflow_dispatch: diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpHttpTestSupport.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpHttpTestSupport.java new file mode 100644 index 0000000000..8cbe11b44f --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpHttpTestSupport.java @@ -0,0 +1,59 @@ +package io.stargate.sgv2.jsonapi.api.v1.mcp; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; + +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import io.vertx.core.json.JsonObject; +import java.util.HashMap; +import java.util.Map; + +/** Shared wire format for raw HTTP tests that do not initialize an MCP client session. */ +final class McpHttpTestSupport { + private static final String PROTOCOL_VERSION = "2026-07-28"; + + private McpHttpTestSupport() {} + + static RequestSpecification request(String method) { + return given() + .contentType(ContentType.JSON) + .accept("application/json, text/event-stream") + .header("MCP-Protocol-Version", PROTOCOL_VERSION) + .header("Mcp-Method", method); + } + + static Map toolPayload(int id, String tool, Map arguments) { + return rpcPayload(id, "tools/call", Map.of("name", tool, "arguments", arguments)); + } + + static Map rpcPayload(int id, String method, Map params) { + var requestParams = new HashMap<>(params); + requestParams.put( + "_meta", + Map.of( + "io.modelcontextprotocol/protocolVersion", + PROTOCOL_VERSION, + "io.modelcontextprotocol/clientCapabilities", + Map.of())); + return Map.of("jsonrpc", "2.0", "id", id, "method", method, "params", requestParams); + } + + static JsonObject completeResult(Response response, int id) { + assertThat(response.statusCode()).as(response.asString()).isEqualTo(200); + var body = new JsonObject(response.asString()); + assertThat(body.getInteger("id")).isEqualTo(id); + assertThat(body.containsKey("error")).isFalse(); + var result = body.getJsonObject("result"); + assertThat(result.getString("resultType")).isEqualTo("complete"); + return result; + } + + static JsonObject readableResult(JsonObject result) { + var content = result.getJsonArray("content"); + assertThat(content).hasSize(1); + assertThat(content.getJsonObject(0).getString("type")).isEqualTo("text"); + return new JsonObject(content.getJsonObject(0).getString("text")); + } +} diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java index 44b23f1690..3b96f6bd36 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpProtocolTest.java @@ -1,12 +1,12 @@ package io.stargate.sgv2.jsonapi.api.v1.mcp; -import static io.restassured.RestAssured.given; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.completeResult; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.request; +import static io.stargate.sgv2.jsonapi.api.v1.mcp.McpHttpTestSupport.rpcPayload; import static org.assertj.core.api.Assertions.assertThat; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.junit.TestProfile; -import io.restassured.http.ContentType; -import io.restassured.response.Response; import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; import java.util.Map; import org.junit.jupiter.api.Test; @@ -15,45 +15,19 @@ @QuarkusTest @TestProfile(NoGlobalResourcesTestProfile.Impl.class) class McpProtocolTest { - private static final String PROTOCOL_VERSION = "2026-07-28"; - @Test void listsToolsWithoutInitializationOrSession() { // Each POST is independent. clientInfo, discovery and initialization are deliberately absent. for (int id = 1; id <= 2; id++) { - Response response = request("tools/list", PROTOCOL_VERSION, id); - assertThat(response.statusCode()).as(response.asString()).isEqualTo(200); + var response = + request("tools/list") + // Existing header identity fixture only. OAuth admission is tracked in #2642. + .header("Token", "protocol-test-credential") + .body(rpcPayload(id, "tools/list", Map.of())) + .post("/v1/mcp"); + completeResult(response, id); assertThat(response.header("Mcp-Session-Id")).isNull(); - assertThat(response.jsonPath().getInt("id")).isEqualTo(id); assertThat(response.jsonPath().getList("result.tools.name")).isNotEmpty(); - assertThat(response.jsonPath().getString("result.resultType")).isEqualTo("complete"); } } - - private Response request(String method, String version, int id) { - return given() - .contentType(ContentType.JSON) - .accept("application/json, text/event-stream") - // Existing header identity fixture only. OAuth admission is tracked separately in #2642. - .header("Token", "protocol-test-credential") - .header("MCP-Protocol-Version", version) - .header("Mcp-Method", method) - .body( - Map.of( - "jsonrpc", - "2.0", - "id", - id, - "method", - method, - "params", - Map.of( - "_meta", - Map.of( - "io.modelcontextprotocol/protocolVersion", - version, - "io.modelcontextprotocol/clientCapabilities", - Map.of())))) - .post("/v1/mcp"); - } } From 0900d87343ee631768fdfd55fdfd2966e09b6883 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 22 Oct 2026 10:30:34 -0700 Subject: [PATCH 25/35] Preserve accepted empty rerank configuration maps --- .../command/impl/FindAndRerankCommand.java | 17 ++++- .../v1/mcp/McpClauseSchemaContractTest.java | 76 +++++++++---------- 2 files changed, 51 insertions(+), 42 deletions(-) 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 79b55a7bb8..17b9c65f46 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,9 @@ 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.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; @@ -116,8 +118,7 @@ public record Options( @JsonProperty("rerank") CreateCollectionCommand.Options.RerankServiceDesc rerankServiceOverride) {} - // Collection creation uses the same service record but also accepts authentication and - // parameters. + // Empty authentication and parameter maps preserve service descriptor compatibility. @Schema( name = RerankOverrideSchema.NAME, type = SchemaType.OBJECT, @@ -127,7 +128,17 @@ public record Options( requiredProperties = {PROVIDER, MODEL_NAME}, properties = { @SchemaProperty(name = PROVIDER, type = SchemaType.STRING), - @SchemaProperty(name = MODEL_NAME, type = SchemaType.STRING) + @SchemaProperty(name = MODEL_NAME, type = SchemaType.STRING), + @SchemaProperty( + name = AUTHENTICATION, + type = SchemaType.OBJECT, + nullable = true, + additionalProperties = Schema.False.class), + @SchemaProperty( + name = PARAMETERS, + type = SchemaType.OBJECT, + nullable = true, + additionalProperties = Schema.False.class) }, examples = """ diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 1249c76a97..014340c58d 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -28,8 +28,10 @@ import io.stargate.sgv2.jsonapi.metrics.CommandFeatures; import io.stargate.sgv2.jsonapi.service.provider.ApiModelSupport; import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfig; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfig.RerankingProviderConfig.AuthenticationType; import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl; import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl.RerankingProviderConfigImpl; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl.RerankingProviderConfigImpl.AuthenticationConfigImpl; import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl.RerankingProviderConfigImpl.ModelConfigImpl; import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl.RerankingProviderConfigImpl.ModelConfigImpl.RequestPropertiesImpl; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionRerankDef; @@ -348,12 +350,10 @@ void rerankToolUsesOverrideFieldDescription() throws Exception { .asText()); } - /** - * Required fields are enforced by service validation, so schema acceptance must agree with it. - */ + /** Schema acceptance must agree with service validation of the override fields. */ @ParameterizedTest - @MethodSource("rerankOverrideRequiredFields") - void rerankOverrideSchemaMatchesRequiredFieldValidation(String json, boolean accepted) + @MethodSource("rerankOverrides") + void rerankOverrideSchemaMatchesServiceValidation(String json, boolean accepted) throws Exception { var schema = inputSchema("findAndRerank"); var payload = objectMapper.readTree(json); @@ -385,13 +385,37 @@ void rerankOverrideSchemaMatchesRequiredFieldValidation(String json, boolean acc } } - static Stream rerankOverrideRequiredFields() { + static Stream rerankOverrides() { return Stream.of( Arguments.of( """ {"provider": "nvidia", "modelName": "nvidia/rerank-v1"} """, true), + Arguments.of( + """ + {"provider": "nvidia", "modelName": "nvidia/rerank-v1", + "authentication": {}, "parameters": {}} + """, + true), + Arguments.of( + """ + {"provider": "nvidia", "modelName": "nvidia/rerank-v1", + "authentication": null, "parameters": null} + """, + true), + Arguments.of( + """ + {"provider": "nvidia", "modelName": "nvidia/rerank-v1", + "authentication": {"providerKey": "credential-reference"}} + """, + false), + Arguments.of( + """ + {"provider": "nvidia", "modelName": "nvidia/rerank-v1", + "parameters": {"truncate": "END"}} + """, + false), Arguments.of("{}", false), Arguments.of( """ @@ -432,7 +456,12 @@ private static RerankingProvidersConfig supportedRerankingProvider() { return new RerankingProvidersConfigImpl( Map.of( "nvidia", - new RerankingProviderConfigImpl(false, "nvidia", true, Map.of(), List.of(model)))); + new RerankingProviderConfigImpl( + false, + "nvidia", + true, + Map.of(AuthenticationType.NONE, new AuthenticationConfigImpl(true, List.of())), + List.of(model)))); } @ParameterizedTest @@ -520,21 +549,7 @@ static Stream invalidToolPayloads() { Arguments.of("findAndRerank", "{\"sort\": {\"vectorizeSort\": \"query\"}}"), Arguments.of("findAndRerank", "{\"sort\": {\"$hybrid\": {\"$vector\": \"query\"}}}"), Arguments.of("findAndRerank", "{\"options\": {\"hybridLimits\": {\"$vector\": 50}}}"), - Arguments.of("findAndRerank", "{\"options\": {\"rerank\": {\"provider\": \"nvidia\"}}}"), - Arguments.of( - "findAndRerank", - """ - {"options": {"rerank": { - "provider": "nvidia", "modelName": "nvidia/rerank-v1", "authentication": {} - }}} - """), - Arguments.of( - "findAndRerank", - """ - {"options": {"rerank": { - "provider": "nvidia", "modelName": "nvidia/rerank-v1", "parameters": {} - }}} - """)); + Arguments.of("findAndRerank", "{\"options\": {\"rerank\": {\"provider\": \"nvidia\"}}}")); } private ObjectNode toolInput(String json) throws Exception { @@ -544,14 +559,6 @@ private ObjectNode toolInput(String json) throws Exception { .put("collection", "documents"); } - @Test - void openApiSortExamplesAreValidClauseJson() throws Exception { - var schema = FindAndRerankSort.class.getAnnotation(Schema.class); - for (var example : schema.examples()) { - assertNotNull(objectMapper.readValue(example, FindAndRerankSort.class)); - } - } - /** Positive examples alone would also pass against a schema that accepts every JSON value. */ @ParameterizedTest @MethodSource("invalidWireShapes") @@ -576,15 +583,6 @@ static Stream invalidWireShapes() { FindAndRerankCommand.HybridLimits.class, "{\"$vector\": 50, \"$lexical\": \"10\"}")); } - @Test - void hybridLimitAnnotationExamplesAreValidClauseJson() throws Exception { - var schema = FindAndRerankCommand.HybridLimits.class.getAnnotation(Schema.class); - assertTrue(schema.examples().length > 0); - for (var example : schema.examples()) { - assertNotNull(objectMapper.readValue(example, FindAndRerankCommand.HybridLimits.class)); - } - } - private void assertSchemaAccepts(JsonNode schema, JsonNode example) { var validator = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012).getSchema(schema); From ab4d996139ba3afd67504cedc4158dd15c3ae8a1 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 22 Oct 2026 10:34:43 -0700 Subject: [PATCH 26/35] Keep rerank authentication validation provider specific --- .../command/impl/FindAndRerankCommand.java | 4 +- .../v1/mcp/McpClauseSchemaContractTest.java | 61 +++++++++++++------ 2 files changed, 44 insertions(+), 21 deletions(-) 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 17b9c65f46..4e45a2afb5 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 @@ -118,7 +118,7 @@ public record Options( @JsonProperty("rerank") CreateCollectionCommand.Options.RerankServiceDesc rerankServiceOverride) {} - // Empty authentication and parameter maps preserve service descriptor compatibility. + // Authentication support depends on the provider. Parameters allow only empty maps or null. @Schema( name = RerankOverrideSchema.NAME, type = SchemaType.OBJECT, @@ -133,7 +133,7 @@ public record Options( name = AUTHENTICATION, type = SchemaType.OBJECT, nullable = true, - additionalProperties = Schema.False.class), + additionalProperties = String.class), @SchemaProperty( name = PARAMETERS, type = SchemaType.OBJECT, diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 014340c58d..7adc7b0c83 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -51,6 +51,7 @@ import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; @@ -366,17 +367,7 @@ void rerankOverrideSchemaMatchesServiceValidation(String json, boolean accepted) assertEquals( accepted, errors.isEmpty(), () -> "Schema validation for " + json + " returned " + errors); - var override = - objectMapper.treeToValue(payload, CreateCollectionCommand.Options.RerankServiceDesc.class); - Executable validate = - () -> - CollectionRerankDef.validateServiceDesc( - supportedRerankingProvider(), - override.provider(), - override.modelName(), - override.authentication(), - override.parameters(), - RequestException.Code.INVALID_RERANK_OVERRIDE); + Executable validate = () -> validateRerankOverride(payload, AuthenticationType.NONE); if (accepted) { assertDoesNotThrow(validate); } else { @@ -404,12 +395,6 @@ static Stream rerankOverrides() { "authentication": null, "parameters": null} """, true), - Arguments.of( - """ - {"provider": "nvidia", "modelName": "nvidia/rerank-v1", - "authentication": {"providerKey": "credential-reference"}} - """, - false), Arguments.of( """ {"provider": "nvidia", "modelName": "nvidia/rerank-v1", @@ -444,7 +429,45 @@ static Stream rerankOverrides() { false)); } - private static RerankingProvidersConfig supportedRerankingProvider() { + /** The schema allows credentials because the provider configuration determines support. */ + @ParameterizedTest + @EnumSource(AuthenticationType.class) + void rerankAuthenticationValidationDependsOnProvider(AuthenticationType authenticationType) + throws Exception { + var payload = + objectMapper.readTree( + """ + {"provider": "nvidia", "modelName": "nvidia/rerank-v1", + "authentication": {"providerKey": "credential-reference"}} + """); + var command = toolInput("{}"); + command.putObject("options").set("rerank", payload); + assertSchemaAccepts(inputSchema("findAndRerank"), command); + + Executable validate = () -> validateRerankOverride(payload, authenticationType); + if (authenticationType == AuthenticationType.SHARED_SECRET) { + assertDoesNotThrow(validate); + } else { + var error = assertThrows(RequestException.class, validate); + assertEquals(RequestException.Code.INVALID_RERANK_OVERRIDE.name(), error.code); + } + } + + private CollectionRerankDef.RerankServiceDef validateRerankOverride( + JsonNode payload, AuthenticationType authenticationType) throws Exception { + var override = + objectMapper.treeToValue(payload, CreateCollectionCommand.Options.RerankServiceDesc.class); + return CollectionRerankDef.validateServiceDesc( + supportedRerankingProvider(authenticationType), + override.provider(), + override.modelName(), + override.authentication(), + override.parameters(), + RequestException.Code.INVALID_RERANK_OVERRIDE); + } + + private static RerankingProvidersConfig supportedRerankingProvider( + AuthenticationType authenticationType) { var model = new ModelConfigImpl( "nvidia/rerank-v1", @@ -460,7 +483,7 @@ private static RerankingProvidersConfig supportedRerankingProvider() { false, "nvidia", true, - Map.of(AuthenticationType.NONE, new AuthenticationConfigImpl(true, List.of())), + Map.of(authenticationType, new AuthenticationConfigImpl(true, List.of())), List.of(model)))); } From 55d3d72f6ddec7d4966636799a7327d3ed17097b Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 22 Oct 2026 18:41:37 -0700 Subject: [PATCH 27/35] Apply review wording and cleanup for MCP clause schemas Use the reviewer's filter and sort descriptions, drop trailing sentences from schema descriptions, align example text blocks, restore the JsonDefinition field comment, rename SCHEMA_MAPS to SCHEMA_OBJECTS, use isBlank for annotation strings, and note that McpSchemaCustomizer absorbed McpSchemaDescriptionCustomizer. --- .../jsonapi/api/model/command/JsonDefinition.java | 2 +- .../model/command/clause/filter/FilterDefinition.java | 7 +++---- .../model/command/clause/filter/SortDefinition.java | 6 +++--- .../model/command/clause/sort/FindAndRerankSort.java | 3 +-- .../api/model/command/impl/FindAndRerankCommand.java | 2 +- .../jsonapi/api/v1/mcp/McpOpenApiSchemaAdapter.java | 5 ++--- .../sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java | 11 +++++++---- 7 files changed, 18 insertions(+), 18 deletions(-) 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 69fc011d6e..592c37981c 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 @@ -16,7 +16,7 @@ * @param Type of the value object to be deserialized from the JSON value */ public abstract class JsonDefinition { - /** The whole clause value. Clients do not send a nested json property. */ + /** The wrapped JSON value */ @Schema(hidden = true) private final JsonNode 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 7f382e7f96..addb222d93 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 @@ -19,15 +19,14 @@ // FilterClause describes parsed state and would incorrectly reserve fields such as empty. implementation = Object.class, nullable = true, - description = - "A Data API filter object mapping document paths to values or operator expressions, such as $eq, $gt, $in, $and, and $or.", + description = "A filter to select documents, see examples.", examples = { """ {"age": {"$gt": 40}} """, """ - {"name": "Aaron", "country": {"$eq": "NZ"}, "age": {"$gt": 40}} - """ + {"name": "Aaron", "country": {"$eq": "NZ"}, "age": {"$gt": 40}} + """ }) public class FilterDefinition extends JsonDefinition { /** 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 f9a8139cde..63eab57073 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 @@ -22,11 +22,11 @@ implementation = Object.class, nullable = true, description = - "A Data API sort object mapping paths to 1 (ascending) or -1 (descending). Collections also accept a single $vector, $vectorize, or $lexical sort. Tables use the vector or lexical column name.", + "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} - """, + {"user.age" : -1, "user.name" : 1} + """, """ {"$vector": [0.1, 0.2, 0.3]} """, 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 a9251c0efe..5e84ba51a4 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 @@ -34,8 +34,7 @@ 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." - + " Query text and required collection capabilities are checked when resolving the command.", + + " $vector and $vectorize cannot be used together.", examples = { """ {"$hybrid": "Same query for vectorize and BM25 sorting"} 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 4e45a2afb5..9632ae833a 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 @@ -154,7 +154,7 @@ public static class RerankOverrideSchema { @Schema( name = HybridLimits.NAME, description = - "Candidate read limits as one integer for both legs, or an object containing both $vector and $lexical integer limits. Accepted bounds are determined by server configuration.", + "Candidate read limits as one integer for both legs, or an object containing both $vector and $lexical integer limits.", implementation = Void.class, anyOf = {Integer.class, HybridLimitValuesSchema.class}, examples = { 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 index ebf1a87fb4..b03270466e 100644 --- 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 @@ -28,7 +28,7 @@ public class McpOpenApiSchemaAdapter implements GlobalInputSchemaGenerator { private static final ObjectMapper MAPPER = new ObjectMapper(); private static final String OPEN_API_REF = "#/components/schemas/"; private static final String DEFINITION_PREFIX = "_openapi_"; - private static final Set SCHEMA_MAPS = + private static final Set SCHEMA_OBJECTS = Set.of("properties", "patternProperties", "$defs", "dependentSchemas"); private static final Set SCHEMA_ARRAYS = Set.of("allOf", "anyOf", "oneOf", "prefixItems"); private static final Set SCHEMA_VALUES = @@ -138,7 +138,7 @@ private static void visitSchemas(JsonNode node, Consumer visitor) { } visitor.accept(schema); // Examples, defaults, and property names can contain literal $ref values. Visit schemas only. - for (var keyword : SCHEMA_MAPS) { + for (var keyword : SCHEMA_OBJECTS) { if (schema.path(keyword).isObject()) { schema.path(keyword).forEach(value -> visitSchemas(value, visitor)); } @@ -159,7 +159,6 @@ private synchronized JsonNode openApi() { if (loader == null) { loader = Thread.currentThread().getContextClassLoader(); } - // Quarkus packages the same build-time document for JVM and native applications. try (var stream = loader.getResourceAsStream(OpenApiConstants.BASE_NAME + ".JSON")) { if (stream == null) { throw new IllegalStateException("The generated OpenAPI document is unavailable"); diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java index 195b4fb9c4..2f3abf2cdd 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java @@ -20,6 +20,9 @@ * *

Named {@code @Schema} definitions refer to the schemas Quarkus already generates for Swagger. * {@link McpOpenApiSchemaAdapter} packages those definitions into the complete MCP input schema. + * + *

This class also includes the field description resolver that was previously in {@code + * McpSchemaDescriptionCustomizer}. */ @Singleton public class McpSchemaCustomizer implements SchemaGeneratorConfigCustomizer { @@ -30,7 +33,7 @@ public void customize(SchemaGeneratorConfigBuilder builder) { .withDescriptionResolver( field -> { var schema = field.getAnnotationConsideringFieldAndGetter(Schema.class); - return schema != null && !schema.description().isEmpty() + return schema != null && !schema.description().isBlank() ? schema.description() : null; }) @@ -60,7 +63,7 @@ public void customize(SchemaGeneratorConfigBuilder builder) { .withDescriptionResolver( type -> { var schema = type.getType().getErasedType().getAnnotation(Schema.class); - return schema != null && !schema.description().isEmpty() + return schema != null && !schema.description().isBlank() ? schema.description() : null; }) @@ -81,10 +84,10 @@ private static ObjectNode schemaNode(Schema annotation, boolean typeAnnotation) return null; } var reference = annotation.ref(); - if (reference.isEmpty()) { + if (reference.isBlank()) { // A type name identifies an OpenAPI component. A field name only names that property. var name = typeAnnotation ? annotation.name() : ""; - if (name.isEmpty()) { + if (name.isBlank()) { return null; } reference = "#/components/schemas/" + name.replace("~", "~0").replace("/", "~1"); From 0b89e718b931c7f965907c16bee5019c7a1eb534 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Thu, 22 Oct 2026 18:56:07 -0700 Subject: [PATCH 28/35] Explain clause schema classes and use JsonIgnore for internal fields Replace @Schema(hidden) with @JsonIgnore on clause wrapper state, restore Map.class on FindAndRerankSort, explain why HybridLimits sets implementation = Void.class, document the schema-only classes, nest the hybrid limit values schema inside HybridLimits, and explain each McpSchemaCustomizer block. The generated OpenAPI document is unchanged. --- .../api/model/command/JsonDefinition.java | 7 ++-- .../clause/filter/FilterDefinition.java | 4 +-- .../command/clause/filter/SortDefinition.java | 6 ++-- .../clause/sort/FindAndRerankSort.java | 18 ++++++++--- .../command/impl/FindAndRerankCommand.java | 32 ++++++++++--------- .../api/v1/mcp/McpSchemaCustomizer.java | 10 ++++++ 6 files changed, 49 insertions(+), 28 deletions(-) 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 592c37981c..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,9 +1,9 @@ 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; -import org.eclipse.microprofile.openapi.annotations.media.Schema; /** * Value type enclosing JSON-encoded representation of something to build into full model object, @@ -11,14 +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 */ - @Schema(hidden = true) - 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 addb222d93..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; @@ -33,8 +34,7 @@ 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}. */ - @Schema(hidden = true) - 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 63eab57073..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; @@ -39,8 +40,7 @@ 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}. */ - @Schema(hidden = true) - private SortClause sortClause; + @JsonIgnore private SortClause sortClause; /** * To deserialize the whole JSON value, need to ensure DELEGATING mode (instead of PROPERTIES). @@ -96,7 +96,7 @@ public boolean hasVsearchClause() { * Helper method needed for logging purposes, where caller does not have access to the {@link * CommandContext}. */ - @Schema(hidden = true) + @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 5e84ba51a4..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 @@ -25,7 +25,7 @@ @Schema( name = "FindAndRerankSort", type = SchemaType.OBJECT, - implementation = Object.class, + implementation = Map.class, nullable = true, additionalProperties = Schema.False.class, properties = @@ -50,13 +50,16 @@ """ }) public record FindAndRerankSort( - @Schema(hidden = true) String vectorizeSort, - @Schema(hidden = true) String lexicalSort, - @Schema(hidden = true) float[] vectorSort, + @JsonIgnore String vectorizeSort, + @JsonIgnore String lexicalSort, + @JsonIgnore float[] vectorSort, @JsonIgnore CommandFeatures commandFeatures) implements Recordable { - // These classes carry nested schema annotations and are never instantiated by the parser. + // 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, @@ -76,6 +79,7 @@ public record FindAndRerankSort( not = ConflictingVectorSortSchema.class) public static class HybridSortSchema {} + /** A binary encoded vector such as {"$binary": "P4AAAEAAAABAQAAA"}. */ @Schema( name = "BinaryVector", type = SchemaType.OBJECT, @@ -86,6 +90,10 @@ public static class HybridSortSchema {} 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, 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 9632ae833a..2832d5d3f4 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 @@ -118,7 +118,7 @@ public record Options( @JsonProperty("rerank") CreateCollectionCommand.Options.RerankServiceDesc rerankServiceOverride) {} - // Authentication support depends on the provider. Parameters allow only empty maps or null. + // Matches the override validation in CollectionRerankDef. Update both together. @Schema( name = RerankOverrideSchema.NAME, type = SchemaType.OBJECT, @@ -150,13 +150,14 @@ public static class RerankOverrideSchema { } @JsonDeserialize(using = HybridLimitsDeserializer.class) - // Keep the scanner from inferring an object type for this union. @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, HybridLimitValuesSchema.class}, + anyOf = {Integer.class, HybridLimits.ValuesSchema.class}, examples = { "50", """ @@ -186,19 +187,20 @@ public DataRecorder recordTo(DataRecorder dataRecorder) { .append("lexicalLimit", lexicalLimit) .append("commandFeatures", commandFeatures); } - } - @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 HybridLimitValuesSchema {} + /** 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. */ public static class HybridLimitsDeserializer extends StdDeserializer { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java index 2f3abf2cdd..2c58aa25f9 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java @@ -14,6 +14,10 @@ /** * Generates MCP schemas using Data API clause definitions and Jackson and OpenAPI annotations. * + *

The MCP server builds its generator with the victools PLAIN_JSON preset and no modules, so by + * default it reads no annotations at all. Without this class, MCP schemas would use Java field + * names, include fields Jackson ignores, and drop every {@code @Schema} description. + * *

MCP clients use these schemas to build tool arguments. Custom deserialization can accept JSON * shapes that differ from the Java fields, so those types need overrides. Ordinary records still * use field-based generation. @@ -30,6 +34,7 @@ public class McpSchemaCustomizer implements SchemaGeneratorConfigCustomizer { public void customize(SchemaGeneratorConfigBuilder builder) { builder .forFields() + // Field descriptions come from @Schema, as they do for Swagger. .withDescriptionResolver( field -> { var schema = field.getAnnotationConsideringFieldAndGetter(Schema.class); @@ -37,12 +42,14 @@ public void customize(SchemaGeneratorConfigBuilder builder) { ? schema.description() : null; }) + // Leave out fields that are not part of the request JSON. .withIgnoreCheck( field -> { var ignore = field.getAnnotationConsideringFieldAndGetter(JsonIgnore.class); var schema = field.getAnnotationConsideringFieldAndGetter(Schema.class); return (ignore != null && ignore.value()) || (schema != null && schema.hidden()); }) + // A field with @Schema(ref) uses that OpenAPI component instead of its Java type. .withCustomDefinitionProvider( (field, context) -> { var schema = @@ -51,6 +58,7 @@ public void customize(SchemaGeneratorConfigBuilder builder) { ? null : new CustomPropertyDefinition(schema, CustomDefinition.AttributeInclusion.YES); }) + // Property names come from @JsonProperty, so MCP sees the JSON names clients send. .withPropertyNameOverrideResolver( field -> { var property = field.getAnnotationConsideringFieldAndGetter(JsonProperty.class); @@ -60,6 +68,7 @@ public void customize(SchemaGeneratorConfigBuilder builder) { builder .forTypesInGeneral() + // Type descriptions come from @Schema on the class. .withDescriptionResolver( type -> { var schema = type.getType().getErasedType().getAnnotation(Schema.class); @@ -67,6 +76,7 @@ public void customize(SchemaGeneratorConfigBuilder builder) { ? schema.description() : null; }) + // A type with a named @Schema uses that OpenAPI component instead of its Java fields. .withCustomDefinitionProvider( (javaType, context) -> { var schema = schemaNode(javaType.getErasedType().getAnnotation(Schema.class), true); From 6e2cf870a81bb5b75dbcbf3c9425fd9e1b205da0 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 23 Oct 2026 09:14:59 -0700 Subject: [PATCH 29/35] refactor: load MCP OpenAPI schemas at startup and document the adapter Address review feedback on McpOpenApiSchemaAdapter. - Inject the delegate through the constructor and read the generated OpenAPI document there. A missing document now stops startup instead of failing tools/list requests, and the synchronized lazy loader is gone. - Replace adapt() with static bundleOpenApiSchemas(), which changes the given schema in place. This drops the input deepCopy, and copyReferencedSchemas() returns its copies instead of filling a parameter. - Convert the bundled tree straight to the Vert.x object the MCP library sends, removing the second JSON text round trip. - Add OpenApiConstants.Components for the components/schemas pointer and ref prefix, plus componentRef() and named JSON Pointer escape helpers shared with McpSchemaCustomizer. - Document the class, @Priority, the $defs prefix, the schema keyword sets, and the argument pointer fix. --- .../command/impl/FindAndRerankCommand.java | 5 +- .../api/v1/mcp/McpOpenApiSchemaAdapter.java | 291 +++++++++++++----- .../api/v1/mcp/McpSchemaCustomizer.java | 2 +- .../config/constants/OpenApiConstants.java | 10 + .../v1/mcp/McpClauseSchemaContractTest.java | 17 +- .../v1/mcp/McpOpenApiSchemaAdapterTest.java | 69 +++-- 6 files changed, 273 insertions(+), 121 deletions(-) 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 2832d5d3f4..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,6 +1,7 @@ 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; @@ -146,7 +147,7 @@ public record Options( """) public static class RerankOverrideSchema { public static final String NAME = "RerankServiceOverride"; - public static final String REF = "#/components/schemas/" + NAME; + public static final String REF = SCHEMA_REF_PREFIX + NAME; } @JsonDeserialize(using = HybridLimitsDeserializer.class) @@ -172,7 +173,7 @@ public record HybridLimits( implements Recordable { public static final String NAME = "HybridLimits"; - public static final String REF = "#/components/schemas/" + NAME; + public static final String REF = SCHEMA_REF_PREFIX + NAME; public static final HybridLimits DEFAULT = new HybridLimits( 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 index b03270466e..841827374c 100644 --- 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 @@ -1,5 +1,10 @@ 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; @@ -12,25 +17,62 @@ 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; /** - * Shares Swagger's generated schemas with MCP while retaining the existing tool generator. + * Makes each MCP tool input schema standalone by copying in the OpenAPI schemas it points to. + * + *

{@link McpSchemaCustomizer} points clause fields at the schemas 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: * - *

OpenAPI stores reusable schemas under components/schemas, while standalone JSON Schema uses - * $defs. Copy only the referenced definitions and adjust their addresses. Keeping references - * preserves validation constraints without expanding or merging schemas. + *

+ * 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 String OPEN_API_REF = "#/components/schemas/"; + private static final TypeReference> JSON_OBJECT = new TypeReference<>() {}; + + /** Start of a pointer to a definition in the tool schema's own {@code $defs}. */ + private static final String DEFS_REF_PREFIX = "#/$defs/"; + + /** + * Start of each copied schema's name in {@code $defs}. The MCP library names its own definitions + * after Java types, so the prefix keeps a copied {@code FilterDefinition} apart from a definition + * the library generated from a Java class with the same name. + */ private static final String DEFINITION_PREFIX = "_openapi_"; + + // The JSON Schema 2020-12 keywords that hold other schemas. The walk follows only these, so a + // "$ref" that appears as data, in an example or a default value, is never rewritten. + + /** Keywords whose value is an object of schemas, such as {@code properties}. */ private static final Set SCHEMA_OBJECTS = Set.of("properties", "patternProperties", "$defs", "dependentSchemas"); + + /** Keywords whose value is an array of schemas, such as {@code anyOf}. */ private static final Set SCHEMA_ARRAYS = Set.of("allOf", "anyOf", "oneOf", "prefixItems"); + + /** Keywords whose value is a single schema, such as {@code items}. */ private static final Set SCHEMA_VALUES = Set.of( "items", @@ -45,99 +87,186 @@ public class McpOpenApiSchemaAdapter implements GlobalInputSchemaGenerator { "unevaluatedItems", "contentSchema"); - @Inject @Delegate GlobalInputSchemaGenerator delegate; - private JsonNode openApi; + private final GlobalInputSchemaGenerator delegate; - public McpOpenApiSchemaAdapter() {} + /** The OpenAPI document Quarkus generated at build time. Read once and never changed. */ + private final JsonNode openApi; - McpOpenApiSchemaAdapter(JsonNode openApi) { - this.openApi = openApi; + /** + * Reads the OpenAPI document once, here, rather than on each request. The MCP HTTP extension + * creates its tool manager, and with it this decorator, while the server starts. A missing + * document therefore stops startup instead of failing each request that lists tools. + */ + @Inject + public McpOpenApiSchemaAdapter(@Delegate GlobalInputSchemaGenerator delegate) { + this.delegate = delegate; + this.openApi = readGeneratedOpenApiDocument(); } + /** + * Generates the tool schema as usual, then copies in the OpenAPI schemas it points to. + * + *

The MCP library calls this each time a client lists tools, once for each tool. + */ @Override public InputSchema generate(ToolInfo tool) { - var generated = delegate.generate(tool); + ObjectNode schema; try { - return new AdaptedInputSchema( - new JsonObject(adapt(MAPPER.readTree(generated.asJson())).toString())); - } catch (IOException e) { + // The interface promises the schema only as JSON text, so read it once to change it. + schema = (ObjectNode) MAPPER.readTree(delegate.generate(tool).asJson()); + } catch (JsonProcessingException e) { + // Not expected: the library wrote this JSON itself. Listing tools fails with this error. throw new IllegalStateException("Cannot read the MCP input schema for " + tool.name(), e); } + bundleOpenApiSchemas(schema, openApi); + // The library sends value(), a Vert.x object. Convert the tree to it without writing JSON text. + return new StandaloneInputSchema(new JsonObject(MAPPER.convertValue(schema, JSON_OBJECT))); } - ObjectNode adapt(JsonNode input) { - ObjectNode result = input.deepCopy(); - rebaseArgumentReferences(result); - var definitions = MAPPER.createObjectNode(); - collectDefinitions(result, definitions); - if (!definitions.isEmpty()) { - var target = result.withObject("/$defs"); - definitions - .properties() - .forEach( - entry -> { - if (target.has(entry.getKey())) { - throw new IllegalStateException( - "Duplicate MCP schema definition " + entry.getKey()); - } - target.set(entry.getKey(), entry.getValue()); - }); + /** + * Changes {@code schema} in place so it no longer points into the OpenAPI document. + * + *

Package-private and static so tests can check the conversion against a small OpenAPI + * document without starting Quarkus. {@link #generate} is the only production caller. + * + * @param schema a tool input schema, which this method changes + * @param openApi the OpenAPI document, which this method only reads + * @throws IllegalStateException if a pointer names a schema the OpenAPI document does not have + */ + static void bundleOpenApiSchemas(ObjectNode schema, JsonNode openApi) { + rebaseArgumentReferences(schema); + var copies = copyReferencedSchemas(schema, openApi); + if (copies.isEmpty()) { + // Most tools have no clause arguments. Leave their schemas without an empty $defs. + return; } - return result; + var definitions = schema.withObject("/$defs"); + copies.forEach( + (name, copy) -> { + // Library definitions never start with the prefix, so a clash is a bug in this class. + // Fail rather than replace a definition. Listing tools fails with this error. + if (definitions.has(name)) { + throw new IllegalStateException("Duplicate MCP schema definition " + name); + } + definitions.set(name, copy); + }); + } + + /** Returns a pointer to the named schema under {@code components/schemas} in OpenAPI. */ + static String componentRef(String name) { + return SCHEMA_REF_PREFIX + escapePointerToken(name); } - private static void rebaseArgumentReferences(ObjectNode input) { - // The fallback generator nests each argument's $defs but leaves its references root-relative. - // Keep argument-local definitions separate to avoid name collisions. - for (var argument : input.path("properties").properties()) { - var schema = argument.getValue(); - if (!schema.path("$defs").isObject()) { + /** + * Reads the OpenAPI document that Quarkus generates at build time and packages with the + * application. Quarkus builds the document Swagger serves from the same file. + * + *

The lookup matches Quarkus's own {@code OpenApiDocumentService}: the class loader the + * OpenAPI extension records while starting, or the current thread's class loader if none is + * recorded. Package-private so tests can use the same packaged document. + */ + static JsonNode readGeneratedOpenApiDocument() { + var loader = OpenApiConstants.classLoader; + if (loader == null) { + loader = Thread.currentThread().getContextClassLoader(); + } + try (var stream = loader.getResourceAsStream(OpenApiConstants.BASE_NAME + ".JSON")) { + if (stream == null) { + // Only if the build stops generating the document, for example when OpenAPI generation is + // turned off. Clause schemas cannot be described without it, so stop startup. + throw new IllegalStateException("The generated OpenAPI document is unavailable"); + } + return MAPPER.readTree(stream); + } catch (IOException e) { + throw new IllegalStateException("Cannot read the generated OpenAPI document", e); + } + } + + /** + * Fixes pointers inside tool arguments that carry their own {@code $defs}. + * + *

The MCP library generates each tool argument separately and nests the result under {@code + * properties}. When an argument's schema has its own {@code $defs}, its pointers still start at + * the document root, where those definitions do not exist: + * + *

+   * before: {"properties": {"options": {"$defs": {"Local": ...},
+   *                                     "properties": {"value": {"$ref": "#/$defs/Local"}}}}}
+   * after:  the inner pointer becomes "#/properties/options/$defs/Local"
+   * 
+ * + *

A pointer changes only when the root lacks the definition and the argument has it, so + * pointers that already work stay as they are. Each argument keeps its own definitions, so two + * arguments can both define {@code Local} without a clash. + */ + private static void rebaseArgumentReferences(ObjectNode schema) { + for (var argument : schema.path("properties").properties()) { + var argumentSchema = argument.getValue(); + if (!argumentSchema.path("$defs").isObject()) { continue; } - var prefix = "#/properties/" + argument.getKey().replace("~", "~0").replace("/", "~1"); + var argumentPointer = "#/properties/" + escapePointerToken(argument.getKey()); visitSchemas( - schema, - nested -> { - var reference = nested.path("$ref").asText(); - if (reference.startsWith("#/$defs/") - && input.at(reference.substring(1)).isMissingNode() - && !schema.at(reference.substring(1)).isMissingNode()) { - nested.put("$ref", prefix + reference.substring(1)); + argumentSchema, + node -> { + var reference = node.path("$ref").asText(); + if (reference.startsWith(DEFS_REF_PREFIX) + && schema.at(reference.substring(1)).isMissingNode() + && !argumentSchema.at(reference.substring(1)).isMissingNode()) { + node.put("$ref", argumentPointer + reference.substring(1)); } }); } } - private void collectDefinitions(JsonNode node, ObjectNode definitions) { - visitSchemas( - node, - schema -> { - var reference = schema.path("$ref").asText(); - if (reference.startsWith(OPEN_API_REF)) { - var suffix = reference.substring(OPEN_API_REF.length()); - var component = suffix.split("/", 2)[0]; - var name = DEFINITION_PREFIX + component.replace("~1", "/").replace("~0", "~"); - var document = openApi(); - if (document.at(reference.substring(1)).isMissingNode()) { + /** + * Rewrites each OpenAPI pointer in {@code schema} to point into {@code $defs}. Returns copies of + * the OpenAPI schemas those pointers need, keyed by their {@code $defs} name. + * + *

Copied schemas can point to further OpenAPI schemas, so they are rewritten and searched as + * well. Each schema is copied once, which also ends the search for schemas that contain + * themselves. + */ + private static Map copyReferencedSchemas(ObjectNode schema, JsonNode openApi) { + var copies = new LinkedHashMap(); + var pending = new ArrayDeque(); + pending.add(schema); + while (!pending.isEmpty()) { + visitSchemas( + pending.remove(), + node -> { + var reference = node.path("$ref").asText(); + if (!reference.startsWith(SCHEMA_REF_PREFIX)) { + return; + } + if (openApi.at(reference.substring(1)).isMissingNode()) { throw new IllegalStateException("Unresolved OpenAPI schema reference " + reference); } - schema.put("$ref", "#/$defs/" + DEFINITION_PREFIX + suffix); - if (!definitions.has(name)) { - var definition = document.at("/components/schemas/" + component).deepCopy(); - // Register before recursion to handle cyclic references. - definitions.set(name, definition); - collectDefinitions(definition, definitions); + // The path is usually a schema name, such as "FilterDefinition", but it can point + // inside one, such as "FilterDefinition/properties/x". Copy the whole named schema and + // keep the rest of the path. + var path = reference.substring(SCHEMA_REF_PREFIX.length()); + var escapedName = path.split("/", 2)[0]; + node.put("$ref", DEFS_REF_PREFIX + DEFINITION_PREFIX + path); + // Pointers escape "~" and "/" in names, but $defs keys hold the plain name. + var name = DEFINITION_PREFIX + unescapePointerToken(escapedName); + if (!copies.containsKey(name)) { + // Copy, because every request shares the document and the copy gets rewritten. + var copy = openApi.at(SCHEMAS_POINTER + escapedName).deepCopy(); + copies.put(name, copy); + pending.add(copy); } - } - }); + }); + } + return copies; } + /** Calls {@code visitor} on {@code node} and on every schema inside it, parents first. */ private static void visitSchemas(JsonNode node, Consumer visitor) { if (!(node instanceof ObjectNode schema)) { return; } visitor.accept(schema); - // Examples, defaults, and property names can contain literal $ref values. Visit schemas only. for (var keyword : SCHEMA_OBJECTS) { if (schema.path(keyword).isObject()) { schema.path(keyword).forEach(value -> visitSchemas(value, visitor)); @@ -153,28 +282,22 @@ private static void visitSchemas(JsonNode node, Consumer visitor) { } } - private synchronized JsonNode openApi() { - if (openApi == null) { - var loader = OpenApiConstants.classLoader; - if (loader == null) { - loader = Thread.currentThread().getContextClassLoader(); - } - try (var stream = loader.getResourceAsStream(OpenApiConstants.BASE_NAME + ".JSON")) { - if (stream == null) { - throw new IllegalStateException("The generated OpenAPI document is unavailable"); - } - openApi = MAPPER.readTree(stream); - } catch (IOException e) { - throw new IllegalStateException("Cannot read the generated OpenAPI document", e); - } - } - return openApi; + /** Escapes a name for a JSON Pointer (RFC 6901): "~" becomes "~0" and "/" becomes "~1". */ + private static String escapePointerToken(String name) { + return name.replace("~", "~0").replace("/", "~1"); + } + + /** Reverses {@link #escapePointerToken}, replacing "~1" first as RFC 6901 requires. */ + private static String unescapePointerToken(String token) { + return token.replace("~1", "/").replace("~0", "~"); } - private record AdaptedInputSchema(JsonObject value) implements InputSchema { + /** The tool schema as the Vert.x object the MCP library puts into its tool list. */ + private record StandaloneInputSchema(JsonObject value) implements InputSchema { @Override public String asJson() { - return value.encode(); + // Matches the library's own InputSchema implementation. + return value.toString(); } } } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java index 2c58aa25f9..5dbe9c9534 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpSchemaCustomizer.java @@ -100,7 +100,7 @@ private static ObjectNode schemaNode(Schema annotation, boolean typeAnnotation) if (name.isBlank()) { return null; } - reference = "#/components/schemas/" + name.replace("~", "~0").replace("/", "~1"); + reference = McpOpenApiSchemaAdapter.componentRef(name); } return JsonNodeFactory.instance.objectNode().put("$ref", reference); } diff --git a/src/main/java/io/stargate/sgv2/jsonapi/config/constants/OpenApiConstants.java b/src/main/java/io/stargate/sgv2/jsonapi/config/constants/OpenApiConstants.java index 9412cff336..87e6fc1c5d 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/config/constants/OpenApiConstants.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/config/constants/OpenApiConstants.java @@ -8,4 +8,14 @@ interface SecuritySchemes { String TOKEN = "Token"; } + + /** Locations of the reusable schemas in the OpenAPI document. */ + interface Components { + + /** JSON Pointer to the reusable schemas, for reading them from the document. */ + String SCHEMAS_POINTER = "/components/schemas/"; + + /** Start of a {@code $ref} to a reusable schema. Add the schema name to the end. */ + String SCHEMA_REF_PREFIX = "#" + SCHEMAS_POINTER; + } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 7adc7b0c83..64c5fb5433 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -186,8 +186,8 @@ void openApiAndMcpShareClauseSchemas(Class clauseType) throws Exception { var openApiSchema = document.path("components").path("schemas").path(name); assertFalse(openApiSchema.isMissingNode(), "Missing OpenAPI component " + name); var expected = - new McpOpenApiSchemaAdapter(document) - .adapt(objectMapper.createObjectNode().put("$ref", "#/components/schemas/" + name)); + objectMapper.createObjectNode().put("$ref", McpOpenApiSchemaAdapter.componentRef(name)); + McpOpenApiSchemaAdapter.bundleOpenApiSchemas(expected, document); var actual = objectMapper.readTree(schemaFor(clauseType).toString()); assertEquals(referencedSchema(expected, expected), referencedSchema(actual, actual)); assertEquals(expected.path("$defs"), actual.path("$defs")); @@ -242,11 +242,8 @@ void openApiCommandsReferenceSharedSchemas() throws Exception { .deepCopy(); ((ObjectNode) mcpOverride).remove("description"); var expected = - new McpOpenApiSchemaAdapter(document) - .adapt( - objectMapper - .createObjectNode() - .put("$ref", "#/components/schemas/RerankServiceOverride")); + objectMapper.createObjectNode().put("$ref", FindAndRerankCommand.RerankOverrideSchema.REF); + McpOpenApiSchemaAdapter.bundleOpenApiSchemas(expected, document); assertEquals(referencedSchema(expected, expected), mcpOverride); } @@ -661,7 +658,9 @@ private JsonNode schemaFor(Class type) { objectMapper, SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON) .without(Option.SCHEMA_VERSION_INDICATOR); new McpSchemaCustomizer().customize(builder); - return new McpOpenApiSchemaAdapter() - .adapt(new SchemaGenerator(builder.build()).generateSchema(type)); + var schema = new SchemaGenerator(builder.build()).generateSchema(type); + McpOpenApiSchemaAdapter.bundleOpenApiSchemas( + schema, McpOpenApiSchemaAdapter.readGeneratedOpenApiDocument()); + return schema; } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java index 24a685dc9e..b093b66e99 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapterTest.java @@ -15,7 +15,7 @@ class McpOpenApiSchemaAdapterTest { @Test void shouldCopyOnlyReachableComponentsAndRebaseTheirReferences() throws Exception { - var schema = adapt("{\"$ref\":\"#/components/schemas/ObjectValue\"}"); + var schema = bundle("{\"$ref\":\"#/components/schemas/ObjectValue\"}"); assertEquals("#/$defs/_openapi_ObjectValue", schema.path("$ref").asText()); var definitions = schema.path("$defs"); @@ -47,7 +47,7 @@ void shouldLeaveFallbackSchemasAndExistingDefinitionsUnchanged() throws Exceptio } """); - var adapted = new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(input); + var adapted = bundle(input); assertEquals(input, adapted); assertAccepts(adapted, "{\"value\":\"text\"}"); @@ -85,15 +85,13 @@ void shouldResolveFallbackDefinitionsWithinEachToolArgument() throws Exception { } } """); - var original = input.deepCopy(); - var schema = new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(input); + var schema = bundle(input); - assertEquals(original, input); assertEquals( "#/properties/options~1~0/$defs/Local", schema.at("/properties/options~1~0/properties/value/$ref").asText()); assertEquals( - original.at("/properties/options~1~0/examples"), + input.at("/properties/options~1~0/examples"), schema.at("/properties/options~1~0/examples")); assertAccepts( schema, @@ -121,7 +119,7 @@ void shouldPreserveValidRootReferencesWhenArgumentDefinitionsHaveTheSameName() t "$defs":{"Local":{"type":"string"}} } """); - var schema = new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(input); + var schema = bundle(input); assertEquals(input, schema); assertAccepts(schema, "{\"options\":{\"value\":\"text\"}}"); @@ -131,7 +129,7 @@ void shouldPreserveValidRootReferencesWhenArgumentDefinitionsHaveTheSameName() t @Test void shouldRetainExistingDefinitionsWhenAddingOpenApiComponents() throws Exception { var schema = - adapt( + bundle( """ { "type":"object", @@ -152,7 +150,7 @@ void shouldRetainExistingDefinitionsWhenAddingOpenApiComponents() throws Excepti @Test void shouldSupportRecursiveComponentsWithoutExpandingThem() throws Exception { - var schema = adapt("{\"$ref\":\"#/components/schemas/Recursive\"}"); + var schema = bundle("{\"$ref\":\"#/components/schemas/Recursive\"}"); assertEquals(1, schema.path("$defs").size()); assertEquals( @@ -191,7 +189,7 @@ void shouldPreserveEscapedComponentNamesAndNestedPointerSuffixes() throws Except } """); - var schema = new McpOpenApiSchemaAdapter(document).adapt(input); + var schema = bundle(input, document); assertTrue(schema.path("$defs").has("_openapi_A/B~C")); assertEquals( @@ -205,7 +203,7 @@ void shouldPreserveEscapedComponentNamesAndNestedPointerSuffixes() throws Except @Test void shouldRetainOverlappingConstraintsOnReferenceSiblings() throws Exception { - var schema = adapt("{\"$ref\":\"#/components/schemas/Value\",\"maximum\":20}"); + var schema = bundle("{\"$ref\":\"#/components/schemas/Value\",\"maximum\":20}"); assertEquals(20, schema.path("maximum").asInt()); assertAccepts(schema, "10"); @@ -215,7 +213,7 @@ void shouldRetainOverlappingConstraintsOnReferenceSiblings() throws Exception { @Test void shouldRetainDisjointConstraintsOnReferenceSiblings() throws Exception { var schema = - adapt("{\"$ref\":\"#/components/schemas/ObjectValue\",\"additionalProperties\":false}"); + bundle("{\"$ref\":\"#/components/schemas/ObjectValue\",\"additionalProperties\":false}"); assertTrue(schema.has("$ref")); assertFalse(schema.path("additionalProperties").asBoolean()); @@ -235,8 +233,7 @@ void shouldKeepReferenceSiblingsInsideCopiedComponents() throws Exception { .path("a")) .put("maximum", 20); var schema = - new McpOpenApiSchemaAdapter(document) - .adapt(MAPPER.readTree("{\"$ref\":\"#/components/schemas/ObjectValue\"}")); + bundle(MAPPER.readTree("{\"$ref\":\"#/components/schemas/ObjectValue\"}"), document); assertAccepts(schema, "{\"a\":10}"); assertRejects(schema, "{\"a\":15}"); @@ -256,7 +253,7 @@ void shouldPreserveAPropertyNamedRefAndLiteralReferencesInInstanceData() throws "enum":[{"$ref":"#/components/schemas/Unknown"}] } """); - var schema = new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(input); + var schema = bundle(input); assertEquals( "#/$defs/_openapi_Value", schema.path("properties").path("$ref").path("$ref").asText()); @@ -266,31 +263,53 @@ void shouldPreserveAPropertyNamedRefAndLiteralReferencesInInstanceData() throws } @Test - void shouldNotMutateInputsOrShareMutableComponentCopies() throws Exception { + void shouldChangeTheGivenSchemaInPlace() throws Exception { + var schema = (ObjectNode) MAPPER.readTree("{\"$ref\":\"#/components/schemas/Value\"}"); + + McpOpenApiSchemaAdapter.bundleOpenApiSchemas(schema, fixtureDocument()); + + assertEquals("#/$defs/_openapi_Value", schema.path("$ref").asText()); + assertTrue(schema.path("$defs").has("_openapi_Value")); + } + + @Test + void shouldNotChangeTheOpenApiDocumentOrShareComponentCopies() throws Exception { var document = fixtureDocument(); var originalDocument = document.deepCopy(); var input = MAPPER.readTree("{\"$ref\":\"#/components/schemas/Value\"}"); - var originalInput = input.deepCopy(); - var adapter = new McpOpenApiSchemaAdapter(document); - var schema = adapter.adapt(input); + var schema = bundle(input, document); ((ObjectNode) schema.path("$defs").path("_openapi_Value")).put("maximum", 100); assertEquals(originalDocument, document); - assertEquals(originalInput, input); - assertRejects(adapter.adapt(input), "15"); + assertRejects(bundle(input, document), "15"); } @Test void shouldRejectMissingComponentReferences() throws Exception { - var adapter = new McpOpenApiSchemaAdapter(fixtureDocument()); var input = MAPPER.readTree("{\"$ref\":\"#/components/schemas/Missing\"}"); - assertThrows(IllegalStateException.class, () -> adapter.adapt(input)); + assertThrows(IllegalStateException.class, () -> bundle(input)); + } + + @Test + void shouldEscapeComponentNamesInReferences() { + assertEquals("#/components/schemas/A~1B~0C", McpOpenApiSchemaAdapter.componentRef("A/B~C")); + } + + /** Bundles a copy of {@code input}, so tests can compare the result with the input. */ + private static ObjectNode bundle(JsonNode input, JsonNode document) { + ObjectNode schema = input.deepCopy(); + McpOpenApiSchemaAdapter.bundleOpenApiSchemas(schema, document); + return schema; + } + + private static ObjectNode bundle(JsonNode input) throws Exception { + return bundle(input, fixtureDocument()); } - private static ObjectNode adapt(String input) throws Exception { - return new McpOpenApiSchemaAdapter(fixtureDocument()).adapt(MAPPER.readTree(input)); + private static ObjectNode bundle(String input) throws Exception { + return bundle(MAPPER.readTree(input)); } private static void assertAccepts(JsonNode schema, String input) throws Exception { From d3c2429b0fbb65c09442e4431c28cfa8bc96804a Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 23 Oct 2026 09:25:30 -0700 Subject: [PATCH 30/35] ci: restore the original pull request branch filter --- .github/workflows/continuous-integration.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/continuous-integration.yaml b/.github/workflows/continuous-integration.yaml index db25a40c5f..2a3541648c 100644 --- a/.github/workflows/continuous-integration.yaml +++ b/.github/workflows/continuous-integration.yaml @@ -9,7 +9,7 @@ on: branches: [ "main", "feature/*" ] pull_request: - branches: [ "main", "feature/*", "fix/mcp-*" ] + branches: [ "main", "feature/*" ] workflow_dispatch: From 9692939dd0115f4bf937eea63cb382ff3732ceb6 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 23 Oct 2026 09:32:00 -0700 Subject: [PATCH 31/35] fix: describe every findAndRerank MCP tool argument Bring the findAndRerank tool arguments to one standard, following review feedback. - filter now takes its description from FilterDefinition, as sort already does from FindAndRerankSort. A @ToolArg description replaces the class description, so it was showing only "filter". - projection and options get real descriptions in place of "projection" and "Reranking options". - collection says "Name of the collection", since findAndRerank has no table support. Add contract tests that the filter description comes from its class and that no argument is described only by its own name. --- .../api/v1/mcp/CollectionCommandTools.java | 16 ++++++++++---- .../v1/mcp/McpClauseSchemaContractTest.java | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) 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 0ca9bf97dd..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 @@ -151,11 +151,19 @@ public Uni estimatedDocumentCount( @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 = "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 = "Reranking options", required = false) + @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); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 64c5fb5433..a20e26f975 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -333,6 +333,28 @@ void rerankToolUsesSortClassDescription() throws Exception { schemaMetadata(document, propertySchema(document, "sort"), "description").asText()); } + @Test + void rerankToolUsesFilterClassDescription() throws Exception { + var description = FilterDefinition.class.getAnnotation(Schema.class).description(); + assertFalse(description.isBlank()); + var document = inputSchema("findAndRerank"); + assertEquals( + description, + schemaMetadata(document, propertySchema(document, "filter"), "description").asText()); + } + + /** A description that only repeats the argument name tells a client nothing. */ + @Test + void rerankToolDescribesEveryArgument() throws Exception { + var document = inputSchema("findAndRerank"); + for (var argument : document.path("properties").properties()) { + var name = argument.getKey(); + var description = schemaMetadata(document, argument.getValue(), "description").asText(); + assertFalse(description.isBlank(), name); + assertNotEquals(name, description.toLowerCase(), name); + } + } + @Test void rerankToolUsesOverrideFieldDescription() throws Exception { var description = From 94d14c7048426bcb070777d0503eb223c52722d5 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 23 Oct 2026 09:48:47 -0700 Subject: [PATCH 32/35] fix: describe every MCP tool argument to one standard Apply the findAndRerank argument standard to all MCP tools, following review feedback. - filter and sort arguments take their descriptions from FilterDefinition and SortDefinition, which Swagger also uses. A @ToolArg description replaced them, so clients saw only "filter" or "sort". - Collection arguments name what the command supports: the collection, the table, or a collection or table, based on CommandName targets. - Projection, update, replacement, document, definition, and options arguments get real descriptions in place of their bare names or generic text. - Descriptions drop "Optional" and "Required" prefixes, since the schema already lists required arguments, and use consistent capitalization. Generalize the contract tests to every tool: filter and sort use their class descriptions, and no argument is described only by its name or starts with "Optional" or "Required". --- .../api/v1/mcp/CollectionCommandTools.java | 187 +++++++++++------- .../api/v1/mcp/GeneralCommandTools.java | 10 +- .../api/v1/mcp/KeyspaceCommandTools.java | 81 +++++--- .../v1/mcp/McpClauseSchemaContractTest.java | 54 +++-- 4 files changed, 216 insertions(+), 116 deletions(-) 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 efb89bf227..02551a86fc 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 @@ -22,6 +22,9 @@ * MCP tool provider for collection-level (and table-level) commands {@link * io.stargate.sgv2.jsonapi.api.model.command.CollectionCommand}. These correspond to the {@link * io.stargate.sgv2.jsonapi.api.v1.CollectionResource} REST endpoint. + * + *

The filter and sort arguments have no description here. A {@code @ToolArg} description would + * replace the one on their classes, which Swagger also uses. */ public class CollectionCommandTools { @@ -31,7 +34,10 @@ public class CollectionCommandTools { public Uni alterTable( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg(description = "The alter table operation") AlterTableOperation operation) { + @ToolArg( + description = + "Change to make: add or drop columns, or add or drop vectorize for columns") + AlterTableOperation operation) { var command = new AlterTableCommand(operation); return mcpResource.processCollectionCommand(keyspace, table, command); @@ -41,7 +47,7 @@ public Uni alterTable( public Uni countDocuments( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the collection") String collection, - @ToolArg(description = "filter", required = false) FilterDefinition filter) { + @ToolArg(required = false) FilterDefinition filter) { var command = new CountDocumentsCommand(filter); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -51,17 +57,20 @@ public Uni countDocuments( public Uni createIndex( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg(description = "Name of the new Index") String indexName, - @ToolArg(description = "Definition of the index to create") + @ToolArg(description = "Name of the new index") String indexName, + @ToolArg(description = "Column to index and its index options") RegularIndexDefinitionDesc definition, @ToolArg( description = - "Optional type of the index to create. The only supported value is '" + "Type of the index to create. The only supported value is '" + ApiIndexType.Constants.REGULAR + "'.", required = false) String indexType, - @ToolArg(description = "Options for the command.", required = false) + @ToolArg( + description = + "Set ifNotExists to true to succeed when an index with this name exists", + required = false) CreateIndexCommand.CreateIndexCommandOptions options) { var command = new CreateIndexCommand(indexName, definition, indexType, options); @@ -74,17 +83,20 @@ public Uni createIndex( public Uni createTextIndex( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg(description = "Name of the new Index") String indexName, - @ToolArg(description = "Definition of the index to create") + @ToolArg(description = "Name of the new index") String indexName, + @ToolArg(description = "Column to index and its index options") TextIndexDefinitionDesc definition, @ToolArg( description = - "Optional type of the index to create. The only supported value is '" + "Type of the index to create. The only supported value is '" + ApiIndexType.Constants.TEXT + "'.", required = false) String indexType, - @ToolArg(description = "Options for the command.", required = false) + @ToolArg( + description = + "Set ifNotExists to true to succeed when an index with this name exists", + required = false) CreateTextIndexCommand.CommandOptions options) { var command = new CreateTextIndexCommand(indexName, definition, indexType, options); @@ -95,17 +107,20 @@ public Uni createTextIndex( public Uni createVectorIndex( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg(description = "Name of the new Index") String indexName, - @ToolArg(description = "Definition of the index to create") + @ToolArg(description = "Name of the new index") String indexName, + @ToolArg(description = "Column to index and its index options") VectorIndexDefinitionDesc definition, @ToolArg( description = - "Optional type of the index to create. The only supported value is '" + "Type of the index to create. The only supported value is '" + ApiIndexType.Constants.VECTOR + "'.", required = false) String indexType, - @ToolArg(description = "Options for the command.", required = false) + @ToolArg( + description = + "Set ifNotExists to true to succeed when an index with this name exists", + required = false) CreateVectorIndexCommand.CreateVectorIndexCommandOptions options) { var command = new CreateVectorIndexCommand(indexName, definition, indexType, options); @@ -117,11 +132,8 @@ public Uni createVectorIndex( "Command that finds documents based on the filter and deletes them from a collection") public Uni deleteMany( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection/table") String collection, - @ToolArg( - description = "Filter clause based on which documents are identified", - required = false) - FilterDefinition filter) { + @ToolArg(description = "Name of the collection or table") String collection, + @ToolArg(required = false) FilterDefinition filter) { var command = new DeleteManyCommand(filter); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -130,10 +142,9 @@ public Uni deleteMany( @Tool(description = "Command that finds a single document and deletes it from a collection") public Uni deleteOne( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection/table") String collection, - @ToolArg(description = "Filter clause based on which documents are identified") - FilterDefinition filter, - @ToolArg(description = "Sort clause", required = false) SortDefinition sort) { + @ToolArg(description = "Name of the collection or table") String collection, + @ToolArg FilterDefinition filter, + @ToolArg(required = false) SortDefinition sort) { var command = new DeleteOneCommand(filter, sort); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -152,8 +163,6 @@ public Uni estimatedDocumentCount( public Uni findAndRerank( @ToolArg(description = "Name of the keyspace") String keyspace, @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 = @@ -173,11 +182,18 @@ public Uni findAndRerank( @Tool(description = "Command that finds JSON documents from a collection.") public Uni find( @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) SortDefinition sort, - @ToolArg(description = "options", required = false) FindCommand.Options options) { + @ToolArg(description = "Name of the collection or table") String collection, + @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) SortDefinition sort, + @ToolArg( + description = "Options for result limits, paging, and what the response includes", + required = false) + FindCommand.Options options) { var command = new FindCommand(filter, projection, sort, options); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -188,10 +204,14 @@ public Uni find( "Command that finds a single JSON document from a collection and deletes it. The deleted document is returned") public Uni findOneAndDelete( @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 = "sort", required = false) SortDefinition sort, - @ToolArg(description = "projection", required = false) JsonNode projection) { + @ToolArg(description = "Name of the collection") String collection, + @ToolArg(required = false) FilterDefinition filter, + @ToolArg(required = false) SortDefinition sort, + @ToolArg( + description = + "Fields to include or exclude in the returned document, for example {\"title\": 1}", + required = false) + JsonNode projection) { var command = new FindOneAndDeleteCommand(filter, sort, projection); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -202,12 +222,19 @@ public Uni findOneAndDelete( "Command that finds a single JSON document from a collection and replaces it with the replacement document.") public Uni findOneAndReplace( @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 = "sort", required = false) SortDefinition sort, - @ToolArg(description = "projection", required = false) JsonNode projection, - @ToolArg(description = "replacement") ObjectNode replacement, - @ToolArg(description = "options", required = false) + @ToolArg(description = "Name of the collection") String collection, + @ToolArg(required = false) FilterDefinition filter, + @ToolArg(required = false) SortDefinition sort, + @ToolArg( + description = + "Fields to include or exclude in the returned document, for example {\"title\": 1}", + required = false) + JsonNode projection, + @ToolArg(description = "Document that replaces the matching document") ObjectNode replacement, + @ToolArg( + description = + "Options for upsert and whether to return the document before or after the replacement", + required = false) FindOneAndReplaceCommand.Options options) { var command = new FindOneAndReplaceCommand(filter, sort, projection, replacement, options); @@ -219,12 +246,23 @@ public Uni findOneAndReplace( "Command that finds a single JSON document from a collection and updates the values provided in the update clause.") public Uni findOneAndUpdate( @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) SortDefinition sort, - @ToolArg(description = "update") UpdateClause update, - @ToolArg(description = "options", required = false) FindOneAndUpdateCommand.Options options) { + @ToolArg(description = "Name of the collection") String collection, + @ToolArg(required = false) FilterDefinition filter, + @ToolArg( + description = + "Fields to include or exclude in the returned document, for example {\"title\": 1}", + required = false) + JsonNode projection, + @ToolArg(required = false) SortDefinition sort, + @ToolArg( + description = + "Update operators to apply, for example {\"$set\": {\"status\": \"active\"}}") + UpdateClause update, + @ToolArg( + description = + "Options for upsert and whether to return the document before or after the update", + required = false) + FindOneAndUpdateCommand.Options options) { var command = new FindOneAndUpdateCommand(filter, projection, sort, update, options); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -233,11 +271,16 @@ public Uni findOneAndUpdate( @Tool(description = "Command that finds a single JSON document from a collection.") public Uni findOne( @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) SortDefinition sort, - @ToolArg(description = "options", required = false) FindOneCommand.Options options) { + @ToolArg(description = "Name of the collection or table") String collection, + @ToolArg(required = false) FilterDefinition filter, + @ToolArg( + description = + "Fields to include or exclude in the returned document, for example {\"title\": 1}", + required = false) + JsonNode projection, + @ToolArg(required = false) SortDefinition sort, + @ToolArg(description = "Options for what the response includes", required = false) + FindOneCommand.Options options) { var command = new FindOneCommand(filter, projection, sort, options); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -247,7 +290,9 @@ public Uni findOne( public Uni listIndexes( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg(description = "Options for the `listIndexes` command.", required = false) + @ToolArg( + description = "Set explain to true to include the properties of each index", + required = false) ListIndexesCommand.Options options) { var command = new ListIndexesCommand(options); @@ -257,9 +302,9 @@ public Uni listIndexes( @Tool(description = "Command that inserts multiple JSON documents to a collection.") public Uni insertMany( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection/table") String collection, - @ToolArg(description = "JSON documents to insert") List documents, - @ToolArg(description = "Options for inserting many documents", required = false) + @ToolArg(description = "Name of the collection or table") String collection, + @ToolArg(description = "Documents or rows to insert") List documents, + @ToolArg(description = "Options for insert order and per-document results", required = false) InsertManyCommand.Options options) { var command = new InsertManyCommand(documents, options); @@ -269,8 +314,8 @@ public Uni insertMany( @Tool(description = "Command that inserts a single JSON document to a collection.") public Uni insertOne( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection/table") String collection, - @ToolArg(description = "JSON document to insert") JsonNode document) { + @ToolArg(description = "Name of the collection or table") String collection, + @ToolArg(description = "Document or row to insert") JsonNode document) { var command = new InsertOneCommand(document); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -281,10 +326,14 @@ public Uni insertOne( "Command that finds documents from a collection and updates it with the values provided in the update clause.") public Uni updateMany( @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 = "update") UpdateClause update, - @ToolArg(description = "options", required = false) UpdateManyCommand.Options options) { + @ToolArg(description = "Name of the collection") String collection, + @ToolArg(required = false) FilterDefinition filter, + @ToolArg( + description = + "Update operators to apply, for example {\"$set\": {\"status\": \"active\"}}") + UpdateClause update, + @ToolArg(description = "Options for upsert and paging", required = false) + UpdateManyCommand.Options options) { var command = new UpdateManyCommand(filter, update, options); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -295,11 +344,17 @@ public Uni updateMany( "Command that finds a single JSON document from a table or collection and updates the values provided in the update clause.") public Uni updateOne( @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 = "update") UpdateClause update, - @ToolArg(description = "sort", required = false) SortDefinition sort, - @ToolArg(description = "options", required = false) UpdateOneCommand.Options options) { + @ToolArg(description = "Name of the collection or table") String collection, + @ToolArg(required = false) FilterDefinition filter, + @ToolArg( + description = + "Update operators to apply, for example {\"$set\": {\"status\": \"active\"}}") + UpdateClause update, + @ToolArg(required = false) SortDefinition sort, + @ToolArg( + description = "Set upsert to true to insert a document when none matches the filter", + required = false) + UpdateOneCommand.Options options) { var command = new UpdateOneCommand(filter, update, sort, options); return mcpResource.processCollectionCommand(keyspace, collection, command); diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandTools.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandTools.java index aaf01203ea..16649c5caf 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandTools.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandTools.java @@ -22,8 +22,8 @@ public class GeneralCommandTools { @Tool(description = "Command that creates a keyspace.") public Uni createKeyspace( - @ToolArg(description = "Required name of the new Keyspace") String name, - @ToolArg(description = "Options for creating a new keyspace.", required = false) + @ToolArg(description = "Name of the new keyspace") String name, + @ToolArg(description = "Replication settings for the new keyspace", required = false) CreateKeyspaceCommand.Options options) { var command = new CreateKeyspaceCommand(name, options); @@ -32,7 +32,7 @@ public Uni createKeyspace( @Tool(description = "Command that deletes a Keyspace.") public Uni dropKeyspace( - @ToolArg(description = "Required name of the Keyspace to remove") @NotEmpty String name) { + @ToolArg(description = "Name of the keyspace to drop") @NotEmpty String name) { var command = new DropKeyspaceCommand(name); return mcpResource.processCommand(mcpResource.buildDatabaseContext(command), command); @@ -49,7 +49,7 @@ public Uni findKeyspaces() { public Uni findEmbeddingProviders( @ToolArg( description = - "Optional model status filter: SUPPORTED, DEPRECATED, or END_OF_LIFE. If omitted, only SUPPORTED models are returned.", + "Model status filter: SUPPORTED, DEPRECATED, or END_OF_LIFE. If omitted, only SUPPORTED models are returned.", required = false) FindEmbeddingProvidersCommand.Options options) { @@ -61,7 +61,7 @@ public Uni findEmbeddingProviders( public Uni findRerankingProviders( @ToolArg( description = - "Optional model status filter: SUPPORTED, DEPRECATED, or END_OF_LIFE. If omitted, only SUPPORTED models are returned.", + "Model status filter: SUPPORTED, DEPRECATED, or END_OF_LIFE. If omitted, only SUPPORTED models are returned.", required = false) FindRerankingProvidersCommand.Options options) { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/KeyspaceCommandTools.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/KeyspaceCommandTools.java index faec415caa..aae301dac8 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/KeyspaceCommandTools.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/KeyspaceCommandTools.java @@ -24,9 +24,12 @@ public class KeyspaceCommandTools { */ @Tool(description = "Command that creates a collection.") public Uni createCollection( - @ToolArg(description = "Name of the existing keyspace") String keyspace, + @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the new collection") String collection, - @ToolArg(description = "Configuration options for the collection", required = false) + @ToolArg( + description = + "Collection settings for document IDs, vector search, indexing, lexical search, and reranking", + required = false) CreateCollectionCommand.Options options) { var command = new CreateCollectionCommand(collection, options); @@ -35,8 +38,8 @@ public Uni createCollection( @Tool(description = "Command that deletes a collection if one exists.") public Uni deleteCollection( - @ToolArg(description = "Name of the existing Keyspace") String keyspace, - @ToolArg(description = "Name of the existing Collection") String collection) { + @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg(description = "Name of the collection to delete") String collection) { var command = new DeleteCollectionCommand(collection); return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command); @@ -44,8 +47,10 @@ public Uni deleteCollection( @Tool(description = "Command that lists all available collections in a keyspace.") public Uni findCollections( - @ToolArg(description = "Name of the existing Keyspace") String keyspace, - @ToolArg(description = "include collection properties.", required = false) + @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg( + description = "Set explain to true to include the properties of each collection", + required = false) FindCollectionsCommand.Options options) { var command = new FindCollectionsCommand(options); @@ -57,11 +62,12 @@ public Uni findCollections( */ @Tool(description = "Command that alters a user defined type.") public Uni alterType( - @ToolArg(description = "Keyspace of the type to alter") String keyspace, + @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the type to alter") String name, - @ToolArg(description = "Operation to rename fields", required = false) + @ToolArg(description = "Fields to rename, mapping old names to new names", required = false) RenameTypeFieldsDesc rename, - @ToolArg(description = "Operation to add fields", required = false) TypeDefinitionDesc add) { + @ToolArg(description = "Fields to add to the type", required = false) + TypeDefinitionDesc add) { var command = new AlterTypeCommand(name, rename, add); return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command); @@ -69,10 +75,13 @@ public Uni alterType( @Tool(description = "Command that creates an API Table.") public Uni createTable( - @ToolArg(description = "Name of the existing keyspace") String keyspace, - @ToolArg(description = "Name of the new Table") String table, - @ToolArg(description = "Table definition") TableDefinitionDesc definition, - @ToolArg(description = "Configuration options for the table", required = false) + @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg(description = "Name of the new table") String table, + @ToolArg(description = "Columns and primary key of the new table") + TableDefinitionDesc definition, + @ToolArg( + description = "Set ifNotExists to true to succeed when the table already exists", + required = false) CreateTableCommand.Options options) { var command = new CreateTableCommand(table, definition, options); @@ -81,10 +90,12 @@ public Uni createTable( @Tool(description = "Command that creates a user defined type.") public Uni createType( - @ToolArg(description = "Name of the existing keyspace") String keyspace, - @ToolArg(description = "Name of the type to create") String name, - @ToolArg(description = "Type definition") TypeDefinitionDesc definition, - @ToolArg(description = "Configuration options for the command", required = false) + @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg(description = "Name of the new type") String name, + @ToolArg(description = "Fields of the new type") TypeDefinitionDesc definition, + @ToolArg( + description = "Set ifNotExists to true to succeed when the type already exists", + required = false) CreateTypeCommand.Options options) { var command = new CreateTypeCommand(name, definition, options); @@ -93,9 +104,11 @@ public Uni createType( @Tool(description = "Command that drops an index for a column.") public Uni dropIndex( - @ToolArg(description = "Name of the existing keyspace") String keyspace, - @ToolArg(description = "Name of the Index to remove") String name, - @ToolArg(description = "Dropping index command option.", required = false) + @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg(description = "Name of the index to drop") String name, + @ToolArg( + description = "Set ifExists to true to succeed when the index does not exist", + required = false) DropIndexCommand.Options options) { var command = new DropIndexCommand(name, options); @@ -104,9 +117,11 @@ public Uni dropIndex( @Tool(description = "Command that drops a table if one exists.") public Uni dropTable( - @ToolArg(description = "Name of the existing keyspace") String keyspace, - @ToolArg(description = "Name of the Table to remove") String table, - @ToolArg(description = "Optional options for drop command", required = false) + @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg(description = "Name of the table to drop") String table, + @ToolArg( + description = "Set ifExists to true to succeed when the table does not exist", + required = false) DropTableCommand.Options options) { var command = new DropTableCommand(table, options); @@ -115,9 +130,11 @@ public Uni dropTable( @Tool(description = "Command that drops a user defined type if one exists.") public Uni dropType( - @ToolArg(description = "Name of the existing keyspace") String keyspace, - @ToolArg(description = "Name of the Type to remove") String name, - @ToolArg(description = "Options for drop command", required = false) + @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg(description = "Name of the type to drop") String name, + @ToolArg( + description = "Set ifExists to true to succeed when the type does not exist", + required = false) DropTypeCommand.Options options) { var command = new DropTypeCommand(name, options); @@ -126,8 +143,10 @@ public Uni dropType( @Tool(description = "Command that lists all available tables in a keyspace") public Uni listTables( - @ToolArg(description = "Name of the existing keyspace") String keyspace, - @ToolArg(description = "Options for the `listTables` command.", required = false) + @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg( + description = "Set explain to true to include the properties of each table", + required = false) ListTablesCommand.Options options) { var command = new ListTablesCommand(options); @@ -136,8 +155,10 @@ public Uni listTables( @Tool(description = "Command that lists all available types in a keyspace") public Uni listTypes( - @ToolArg(description = "Name of the existing keyspace") String keyspace, - @ToolArg(description = "Options for the `listTypes` command.", required = false) + @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg( + description = "Set explain to true to include the fields of each type", + required = false) ListTypesCommand.Options options) { var command = new ListTypesCommand(options); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index a20e26f975..4b31dfef11 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -333,25 +333,49 @@ void rerankToolUsesSortClassDescription() throws Exception { schemaMetadata(document, propertySchema(document, "sort"), "description").asText()); } + /** Clause arguments take their descriptions from their classes, which Swagger also uses. */ @Test - void rerankToolUsesFilterClassDescription() throws Exception { - var description = FilterDefinition.class.getAnnotation(Schema.class).description(); - assertFalse(description.isBlank()); - var document = inputSchema("findAndRerank"); - assertEquals( - description, - schemaMetadata(document, propertySchema(document, "filter"), "description").asText()); + void clauseArgumentsUseClassDescriptions() throws Exception { + var filter = FilterDefinition.class.getAnnotation(Schema.class).description(); + var sort = SortDefinition.class.getAnnotation(Schema.class).description(); + var checked = 0; + for (var tool : toolManager) { + var document = inputSchema(tool.name()); + var properties = document.path("properties"); + if (properties.has("filter")) { + assertEquals( + filter, + schemaMetadata(document, properties.path("filter"), "description").asText(), + tool.name()); + checked++; + } + // findAndRerank uses FindAndRerankSort, checked in rerankToolUsesSortClassDescription. + if (properties.has("sort") && !tool.name().equals("findAndRerank")) { + assertEquals( + sort, + schemaMetadata(document, properties.path("sort"), "description").asText(), + tool.name()); + checked++; + } + } + assertTrue(checked > 0, "No tool has a filter or sort argument"); } - /** A description that only repeats the argument name tells a client nothing. */ + /** + * A description that only repeats the argument name tells a client nothing. The schema already + * lists required arguments, so descriptions do not start with "Optional" or "Required". + */ @Test - void rerankToolDescribesEveryArgument() throws Exception { - var document = inputSchema("findAndRerank"); - for (var argument : document.path("properties").properties()) { - var name = argument.getKey(); - var description = schemaMetadata(document, argument.getValue(), "description").asText(); - assertFalse(description.isBlank(), name); - assertNotEquals(name, description.toLowerCase(), name); + void everyToolDescribesEveryArgument() throws Exception { + for (var tool : toolManager) { + var document = inputSchema(tool.name()); + for (var argument : document.path("properties").properties()) { + var label = tool.name() + "." + argument.getKey(); + var description = schemaMetadata(document, argument.getValue(), "description").asText(); + assertFalse(description.isBlank(), label); + assertNotEquals(argument.getKey(), description.toLowerCase(), label); + assertFalse(description.matches("(?i)(optional|required)\\b.*"), label); + } } } From 14cabf237e1d65cc335c8e6fdee276852b170274 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 23 Oct 2026 10:02:04 -0700 Subject: [PATCH 33/35] Revert "fix: describe every MCP tool argument to one standard" This reverts commit 94d14c7048426bcb070777d0503eb223c52722d5. --- .../api/v1/mcp/CollectionCommandTools.java | 187 +++++++----------- .../api/v1/mcp/GeneralCommandTools.java | 10 +- .../api/v1/mcp/KeyspaceCommandTools.java | 81 +++----- .../v1/mcp/McpClauseSchemaContractTest.java | 54 ++--- 4 files changed, 116 insertions(+), 216 deletions(-) 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 02551a86fc..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 @@ -22,9 +22,6 @@ * MCP tool provider for collection-level (and table-level) commands {@link * io.stargate.sgv2.jsonapi.api.model.command.CollectionCommand}. These correspond to the {@link * io.stargate.sgv2.jsonapi.api.v1.CollectionResource} REST endpoint. - * - *

The filter and sort arguments have no description here. A {@code @ToolArg} description would - * replace the one on their classes, which Swagger also uses. */ public class CollectionCommandTools { @@ -34,10 +31,7 @@ public class CollectionCommandTools { public Uni alterTable( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg( - description = - "Change to make: add or drop columns, or add or drop vectorize for columns") - AlterTableOperation operation) { + @ToolArg(description = "The alter table operation") AlterTableOperation operation) { var command = new AlterTableCommand(operation); return mcpResource.processCollectionCommand(keyspace, table, command); @@ -47,7 +41,7 @@ public Uni alterTable( public Uni countDocuments( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the collection") String collection, - @ToolArg(required = false) FilterDefinition filter) { + @ToolArg(description = "filter", required = false) FilterDefinition filter) { var command = new CountDocumentsCommand(filter); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -57,20 +51,17 @@ public Uni countDocuments( public Uni createIndex( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg(description = "Name of the new index") String indexName, - @ToolArg(description = "Column to index and its index options") + @ToolArg(description = "Name of the new Index") String indexName, + @ToolArg(description = "Definition of the index to create") RegularIndexDefinitionDesc definition, @ToolArg( description = - "Type of the index to create. The only supported value is '" + "Optional type of the index to create. The only supported value is '" + ApiIndexType.Constants.REGULAR + "'.", required = false) String indexType, - @ToolArg( - description = - "Set ifNotExists to true to succeed when an index with this name exists", - required = false) + @ToolArg(description = "Options for the command.", required = false) CreateIndexCommand.CreateIndexCommandOptions options) { var command = new CreateIndexCommand(indexName, definition, indexType, options); @@ -83,20 +74,17 @@ public Uni createIndex( public Uni createTextIndex( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg(description = "Name of the new index") String indexName, - @ToolArg(description = "Column to index and its index options") + @ToolArg(description = "Name of the new Index") String indexName, + @ToolArg(description = "Definition of the index to create") TextIndexDefinitionDesc definition, @ToolArg( description = - "Type of the index to create. The only supported value is '" + "Optional type of the index to create. The only supported value is '" + ApiIndexType.Constants.TEXT + "'.", required = false) String indexType, - @ToolArg( - description = - "Set ifNotExists to true to succeed when an index with this name exists", - required = false) + @ToolArg(description = "Options for the command.", required = false) CreateTextIndexCommand.CommandOptions options) { var command = new CreateTextIndexCommand(indexName, definition, indexType, options); @@ -107,20 +95,17 @@ public Uni createTextIndex( public Uni createVectorIndex( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg(description = "Name of the new index") String indexName, - @ToolArg(description = "Column to index and its index options") + @ToolArg(description = "Name of the new Index") String indexName, + @ToolArg(description = "Definition of the index to create") VectorIndexDefinitionDesc definition, @ToolArg( description = - "Type of the index to create. The only supported value is '" + "Optional type of the index to create. The only supported value is '" + ApiIndexType.Constants.VECTOR + "'.", required = false) String indexType, - @ToolArg( - description = - "Set ifNotExists to true to succeed when an index with this name exists", - required = false) + @ToolArg(description = "Options for the command.", required = false) CreateVectorIndexCommand.CreateVectorIndexCommandOptions options) { var command = new CreateVectorIndexCommand(indexName, definition, indexType, options); @@ -132,8 +117,11 @@ public Uni createVectorIndex( "Command that finds documents based on the filter and deletes them from a collection") public Uni deleteMany( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection or table") String collection, - @ToolArg(required = false) FilterDefinition filter) { + @ToolArg(description = "Name of the collection/table") String collection, + @ToolArg( + description = "Filter clause based on which documents are identified", + required = false) + FilterDefinition filter) { var command = new DeleteManyCommand(filter); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -142,9 +130,10 @@ public Uni deleteMany( @Tool(description = "Command that finds a single document and deletes it from a collection") public Uni deleteOne( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection or table") String collection, - @ToolArg FilterDefinition filter, - @ToolArg(required = false) SortDefinition sort) { + @ToolArg(description = "Name of the collection/table") String collection, + @ToolArg(description = "Filter clause based on which documents are identified") + FilterDefinition filter, + @ToolArg(description = "Sort clause", required = false) SortDefinition sort) { var command = new DeleteOneCommand(filter, sort); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -163,6 +152,8 @@ public Uni estimatedDocumentCount( public Uni findAndRerank( @ToolArg(description = "Name of the keyspace") String keyspace, @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 = @@ -182,18 +173,11 @@ public Uni findAndRerank( @Tool(description = "Command that finds JSON documents from a collection.") public Uni find( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection or table") String collection, - @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) SortDefinition sort, - @ToolArg( - description = "Options for result limits, paging, and what the response includes", - required = false) - FindCommand.Options options) { + @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) SortDefinition sort, + @ToolArg(description = "options", required = false) FindCommand.Options options) { var command = new FindCommand(filter, projection, sort, options); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -204,14 +188,10 @@ public Uni find( "Command that finds a single JSON document from a collection and deletes it. The deleted document is returned") public Uni findOneAndDelete( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection") String collection, - @ToolArg(required = false) FilterDefinition filter, - @ToolArg(required = false) SortDefinition sort, - @ToolArg( - description = - "Fields to include or exclude in the returned document, for example {\"title\": 1}", - required = false) - JsonNode projection) { + @ToolArg(description = "Name of the collection/table") String collection, + @ToolArg(description = "filter", required = false) FilterDefinition filter, + @ToolArg(description = "sort", required = false) SortDefinition sort, + @ToolArg(description = "projection", required = false) JsonNode projection) { var command = new FindOneAndDeleteCommand(filter, sort, projection); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -222,19 +202,12 @@ public Uni findOneAndDelete( "Command that finds a single JSON document from a collection and replaces it with the replacement document.") public Uni findOneAndReplace( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection") String collection, - @ToolArg(required = false) FilterDefinition filter, - @ToolArg(required = false) SortDefinition sort, - @ToolArg( - description = - "Fields to include or exclude in the returned document, for example {\"title\": 1}", - required = false) - JsonNode projection, - @ToolArg(description = "Document that replaces the matching document") ObjectNode replacement, - @ToolArg( - description = - "Options for upsert and whether to return the document before or after the replacement", - required = false) + @ToolArg(description = "Name of the collection/table") String collection, + @ToolArg(description = "filter", required = false) FilterDefinition filter, + @ToolArg(description = "sort", required = false) SortDefinition sort, + @ToolArg(description = "projection", required = false) JsonNode projection, + @ToolArg(description = "replacement") ObjectNode replacement, + @ToolArg(description = "options", required = false) FindOneAndReplaceCommand.Options options) { var command = new FindOneAndReplaceCommand(filter, sort, projection, replacement, options); @@ -246,23 +219,12 @@ public Uni findOneAndReplace( "Command that finds a single JSON document from a collection and updates the values provided in the update clause.") public Uni findOneAndUpdate( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection") String collection, - @ToolArg(required = false) FilterDefinition filter, - @ToolArg( - description = - "Fields to include or exclude in the returned document, for example {\"title\": 1}", - required = false) - JsonNode projection, - @ToolArg(required = false) SortDefinition sort, - @ToolArg( - description = - "Update operators to apply, for example {\"$set\": {\"status\": \"active\"}}") - UpdateClause update, - @ToolArg( - description = - "Options for upsert and whether to return the document before or after the update", - required = false) - FindOneAndUpdateCommand.Options options) { + @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) SortDefinition sort, + @ToolArg(description = "update") UpdateClause update, + @ToolArg(description = "options", required = false) FindOneAndUpdateCommand.Options options) { var command = new FindOneAndUpdateCommand(filter, projection, sort, update, options); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -271,16 +233,11 @@ public Uni findOneAndUpdate( @Tool(description = "Command that finds a single JSON document from a collection.") public Uni findOne( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection or table") String collection, - @ToolArg(required = false) FilterDefinition filter, - @ToolArg( - description = - "Fields to include or exclude in the returned document, for example {\"title\": 1}", - required = false) - JsonNode projection, - @ToolArg(required = false) SortDefinition sort, - @ToolArg(description = "Options for what the response includes", required = false) - FindOneCommand.Options options) { + @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) SortDefinition sort, + @ToolArg(description = "options", required = false) FindOneCommand.Options options) { var command = new FindOneCommand(filter, projection, sort, options); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -290,9 +247,7 @@ public Uni findOne( public Uni listIndexes( @ToolArg(description = "Name of the keyspace") String keyspace, @ToolArg(description = "Name of the table") String table, - @ToolArg( - description = "Set explain to true to include the properties of each index", - required = false) + @ToolArg(description = "Options for the `listIndexes` command.", required = false) ListIndexesCommand.Options options) { var command = new ListIndexesCommand(options); @@ -302,9 +257,9 @@ public Uni listIndexes( @Tool(description = "Command that inserts multiple JSON documents to a collection.") public Uni insertMany( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection or table") String collection, - @ToolArg(description = "Documents or rows to insert") List documents, - @ToolArg(description = "Options for insert order and per-document results", required = false) + @ToolArg(description = "Name of the collection/table") String collection, + @ToolArg(description = "JSON documents to insert") List documents, + @ToolArg(description = "Options for inserting many documents", required = false) InsertManyCommand.Options options) { var command = new InsertManyCommand(documents, options); @@ -314,8 +269,8 @@ public Uni insertMany( @Tool(description = "Command that inserts a single JSON document to a collection.") public Uni insertOne( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection or table") String collection, - @ToolArg(description = "Document or row to insert") JsonNode document) { + @ToolArg(description = "Name of the collection/table") String collection, + @ToolArg(description = "JSON document to insert") JsonNode document) { var command = new InsertOneCommand(document); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -326,14 +281,10 @@ public Uni insertOne( "Command that finds documents from a collection and updates it with the values provided in the update clause.") public Uni updateMany( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection") String collection, - @ToolArg(required = false) FilterDefinition filter, - @ToolArg( - description = - "Update operators to apply, for example {\"$set\": {\"status\": \"active\"}}") - UpdateClause update, - @ToolArg(description = "Options for upsert and paging", required = false) - UpdateManyCommand.Options options) { + @ToolArg(description = "Name of the collection/table") String collection, + @ToolArg(description = "filter", required = false) FilterDefinition filter, + @ToolArg(description = "update") UpdateClause update, + @ToolArg(description = "options", required = false) UpdateManyCommand.Options options) { var command = new UpdateManyCommand(filter, update, options); return mcpResource.processCollectionCommand(keyspace, collection, command); @@ -344,17 +295,11 @@ public Uni updateMany( "Command that finds a single JSON document from a table or collection and updates the values provided in the update clause.") public Uni updateOne( @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection or table") String collection, - @ToolArg(required = false) FilterDefinition filter, - @ToolArg( - description = - "Update operators to apply, for example {\"$set\": {\"status\": \"active\"}}") - UpdateClause update, - @ToolArg(required = false) SortDefinition sort, - @ToolArg( - description = "Set upsert to true to insert a document when none matches the filter", - required = false) - UpdateOneCommand.Options options) { + @ToolArg(description = "Name of the collection/table") String collection, + @ToolArg(description = "filter", required = false) FilterDefinition filter, + @ToolArg(description = "update") UpdateClause update, + @ToolArg(description = "sort", required = false) SortDefinition sort, + @ToolArg(description = "options", required = false) UpdateOneCommand.Options options) { var command = new UpdateOneCommand(filter, update, sort, options); return mcpResource.processCollectionCommand(keyspace, collection, command); diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandTools.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandTools.java index 16649c5caf..aaf01203ea 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandTools.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/GeneralCommandTools.java @@ -22,8 +22,8 @@ public class GeneralCommandTools { @Tool(description = "Command that creates a keyspace.") public Uni createKeyspace( - @ToolArg(description = "Name of the new keyspace") String name, - @ToolArg(description = "Replication settings for the new keyspace", required = false) + @ToolArg(description = "Required name of the new Keyspace") String name, + @ToolArg(description = "Options for creating a new keyspace.", required = false) CreateKeyspaceCommand.Options options) { var command = new CreateKeyspaceCommand(name, options); @@ -32,7 +32,7 @@ public Uni createKeyspace( @Tool(description = "Command that deletes a Keyspace.") public Uni dropKeyspace( - @ToolArg(description = "Name of the keyspace to drop") @NotEmpty String name) { + @ToolArg(description = "Required name of the Keyspace to remove") @NotEmpty String name) { var command = new DropKeyspaceCommand(name); return mcpResource.processCommand(mcpResource.buildDatabaseContext(command), command); @@ -49,7 +49,7 @@ public Uni findKeyspaces() { public Uni findEmbeddingProviders( @ToolArg( description = - "Model status filter: SUPPORTED, DEPRECATED, or END_OF_LIFE. If omitted, only SUPPORTED models are returned.", + "Optional model status filter: SUPPORTED, DEPRECATED, or END_OF_LIFE. If omitted, only SUPPORTED models are returned.", required = false) FindEmbeddingProvidersCommand.Options options) { @@ -61,7 +61,7 @@ public Uni findEmbeddingProviders( public Uni findRerankingProviders( @ToolArg( description = - "Model status filter: SUPPORTED, DEPRECATED, or END_OF_LIFE. If omitted, only SUPPORTED models are returned.", + "Optional model status filter: SUPPORTED, DEPRECATED, or END_OF_LIFE. If omitted, only SUPPORTED models are returned.", required = false) FindRerankingProvidersCommand.Options options) { diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/KeyspaceCommandTools.java b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/KeyspaceCommandTools.java index aae301dac8..faec415caa 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/KeyspaceCommandTools.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/v1/mcp/KeyspaceCommandTools.java @@ -24,12 +24,9 @@ public class KeyspaceCommandTools { */ @Tool(description = "Command that creates a collection.") public Uni createCollection( - @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg(description = "Name of the existing keyspace") String keyspace, @ToolArg(description = "Name of the new collection") String collection, - @ToolArg( - description = - "Collection settings for document IDs, vector search, indexing, lexical search, and reranking", - required = false) + @ToolArg(description = "Configuration options for the collection", required = false) CreateCollectionCommand.Options options) { var command = new CreateCollectionCommand(collection, options); @@ -38,8 +35,8 @@ public Uni createCollection( @Tool(description = "Command that deletes a collection if one exists.") public Uni deleteCollection( - @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the collection to delete") String collection) { + @ToolArg(description = "Name of the existing Keyspace") String keyspace, + @ToolArg(description = "Name of the existing Collection") String collection) { var command = new DeleteCollectionCommand(collection); return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command); @@ -47,10 +44,8 @@ public Uni deleteCollection( @Tool(description = "Command that lists all available collections in a keyspace.") public Uni findCollections( - @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg( - description = "Set explain to true to include the properties of each collection", - required = false) + @ToolArg(description = "Name of the existing Keyspace") String keyspace, + @ToolArg(description = "include collection properties.", required = false) FindCollectionsCommand.Options options) { var command = new FindCollectionsCommand(options); @@ -62,12 +57,11 @@ public Uni findCollections( */ @Tool(description = "Command that alters a user defined type.") public Uni alterType( - @ToolArg(description = "Name of the keyspace") String keyspace, + @ToolArg(description = "Keyspace of the type to alter") String keyspace, @ToolArg(description = "Name of the type to alter") String name, - @ToolArg(description = "Fields to rename, mapping old names to new names", required = false) + @ToolArg(description = "Operation to rename fields", required = false) RenameTypeFieldsDesc rename, - @ToolArg(description = "Fields to add to the type", required = false) - TypeDefinitionDesc add) { + @ToolArg(description = "Operation to add fields", required = false) TypeDefinitionDesc add) { var command = new AlterTypeCommand(name, rename, add); return mcpResource.processCommand(mcpResource.buildKeyspaceContext(keyspace, command), command); @@ -75,13 +69,10 @@ public Uni alterType( @Tool(description = "Command that creates an API Table.") public Uni createTable( - @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the new table") String table, - @ToolArg(description = "Columns and primary key of the new table") - TableDefinitionDesc definition, - @ToolArg( - description = "Set ifNotExists to true to succeed when the table already exists", - required = false) + @ToolArg(description = "Name of the existing keyspace") String keyspace, + @ToolArg(description = "Name of the new Table") String table, + @ToolArg(description = "Table definition") TableDefinitionDesc definition, + @ToolArg(description = "Configuration options for the table", required = false) CreateTableCommand.Options options) { var command = new CreateTableCommand(table, definition, options); @@ -90,12 +81,10 @@ public Uni createTable( @Tool(description = "Command that creates a user defined type.") public Uni createType( - @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the new type") String name, - @ToolArg(description = "Fields of the new type") TypeDefinitionDesc definition, - @ToolArg( - description = "Set ifNotExists to true to succeed when the type already exists", - required = false) + @ToolArg(description = "Name of the existing keyspace") String keyspace, + @ToolArg(description = "Name of the type to create") String name, + @ToolArg(description = "Type definition") TypeDefinitionDesc definition, + @ToolArg(description = "Configuration options for the command", required = false) CreateTypeCommand.Options options) { var command = new CreateTypeCommand(name, definition, options); @@ -104,11 +93,9 @@ public Uni createType( @Tool(description = "Command that drops an index for a column.") public Uni dropIndex( - @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the index to drop") String name, - @ToolArg( - description = "Set ifExists to true to succeed when the index does not exist", - required = false) + @ToolArg(description = "Name of the existing keyspace") String keyspace, + @ToolArg(description = "Name of the Index to remove") String name, + @ToolArg(description = "Dropping index command option.", required = false) DropIndexCommand.Options options) { var command = new DropIndexCommand(name, options); @@ -117,11 +104,9 @@ public Uni dropIndex( @Tool(description = "Command that drops a table if one exists.") public Uni dropTable( - @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the table to drop") String table, - @ToolArg( - description = "Set ifExists to true to succeed when the table does not exist", - required = false) + @ToolArg(description = "Name of the existing keyspace") String keyspace, + @ToolArg(description = "Name of the Table to remove") String table, + @ToolArg(description = "Optional options for drop command", required = false) DropTableCommand.Options options) { var command = new DropTableCommand(table, options); @@ -130,11 +115,9 @@ public Uni dropTable( @Tool(description = "Command that drops a user defined type if one exists.") public Uni dropType( - @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg(description = "Name of the type to drop") String name, - @ToolArg( - description = "Set ifExists to true to succeed when the type does not exist", - required = false) + @ToolArg(description = "Name of the existing keyspace") String keyspace, + @ToolArg(description = "Name of the Type to remove") String name, + @ToolArg(description = "Options for drop command", required = false) DropTypeCommand.Options options) { var command = new DropTypeCommand(name, options); @@ -143,10 +126,8 @@ public Uni dropType( @Tool(description = "Command that lists all available tables in a keyspace") public Uni listTables( - @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg( - description = "Set explain to true to include the properties of each table", - required = false) + @ToolArg(description = "Name of the existing keyspace") String keyspace, + @ToolArg(description = "Options for the `listTables` command.", required = false) ListTablesCommand.Options options) { var command = new ListTablesCommand(options); @@ -155,10 +136,8 @@ public Uni listTables( @Tool(description = "Command that lists all available types in a keyspace") public Uni listTypes( - @ToolArg(description = "Name of the keyspace") String keyspace, - @ToolArg( - description = "Set explain to true to include the fields of each type", - required = false) + @ToolArg(description = "Name of the existing keyspace") String keyspace, + @ToolArg(description = "Options for the `listTypes` command.", required = false) ListTypesCommand.Options options) { var command = new ListTypesCommand(options); diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index 4b31dfef11..a20e26f975 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -333,49 +333,25 @@ void rerankToolUsesSortClassDescription() throws Exception { schemaMetadata(document, propertySchema(document, "sort"), "description").asText()); } - /** Clause arguments take their descriptions from their classes, which Swagger also uses. */ @Test - void clauseArgumentsUseClassDescriptions() throws Exception { - var filter = FilterDefinition.class.getAnnotation(Schema.class).description(); - var sort = SortDefinition.class.getAnnotation(Schema.class).description(); - var checked = 0; - for (var tool : toolManager) { - var document = inputSchema(tool.name()); - var properties = document.path("properties"); - if (properties.has("filter")) { - assertEquals( - filter, - schemaMetadata(document, properties.path("filter"), "description").asText(), - tool.name()); - checked++; - } - // findAndRerank uses FindAndRerankSort, checked in rerankToolUsesSortClassDescription. - if (properties.has("sort") && !tool.name().equals("findAndRerank")) { - assertEquals( - sort, - schemaMetadata(document, properties.path("sort"), "description").asText(), - tool.name()); - checked++; - } - } - assertTrue(checked > 0, "No tool has a filter or sort argument"); + void rerankToolUsesFilterClassDescription() throws Exception { + var description = FilterDefinition.class.getAnnotation(Schema.class).description(); + assertFalse(description.isBlank()); + var document = inputSchema("findAndRerank"); + assertEquals( + description, + schemaMetadata(document, propertySchema(document, "filter"), "description").asText()); } - /** - * A description that only repeats the argument name tells a client nothing. The schema already - * lists required arguments, so descriptions do not start with "Optional" or "Required". - */ + /** A description that only repeats the argument name tells a client nothing. */ @Test - void everyToolDescribesEveryArgument() throws Exception { - for (var tool : toolManager) { - var document = inputSchema(tool.name()); - for (var argument : document.path("properties").properties()) { - var label = tool.name() + "." + argument.getKey(); - var description = schemaMetadata(document, argument.getValue(), "description").asText(); - assertFalse(description.isBlank(), label); - assertNotEquals(argument.getKey(), description.toLowerCase(), label); - assertFalse(description.matches("(?i)(optional|required)\\b.*"), label); - } + void rerankToolDescribesEveryArgument() throws Exception { + var document = inputSchema("findAndRerank"); + for (var argument : document.path("properties").properties()) { + var name = argument.getKey(); + var description = schemaMetadata(document, argument.getValue(), "description").asText(); + assertFalse(description.isBlank(), name); + assertNotEquals(name, description.toLowerCase(), name); } } From 941548ac35c120eb3136f5a18a05d8e479ea2c0b Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 23 Oct 2026 10:09:54 -0700 Subject: [PATCH 34/35] docs: name filter and sort as clause arguments in the MCP schema adapter --- .../sgv2/jsonapi/api/v1/mcp/McpOpenApiSchemaAdapter.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index 841827374c..95207f1039 100644 --- 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 @@ -26,9 +26,10 @@ /** * Makes each MCP tool input schema standalone by copying in the OpenAPI schemas it points to. * - *

{@link McpSchemaCustomizer} points clause fields at the schemas 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. + *

{@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: From a7534deb7dda5670bb8850cb06aa7e8159d5e92c Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Fri, 23 Oct 2026 11:45:32 -0700 Subject: [PATCH 35/35] Test every advertised MCP clause schema --- .../jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java index a20e26f975..99f5e33de0 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/mcp/McpClauseSchemaContractTest.java @@ -166,7 +166,6 @@ static Stream> clauseTypes() throws Exception { var schema = type.getAnnotation(Schema.class); // Select parsed clause types, excluding helper classes for nested JSON shapes. if (!schema.name().isBlank() - && schema.examples().length > 0 && (JsonDefinition.class.isAssignableFrom(type) || type.isAnnotationPresent(JsonDeserialize.class))) { clauseTypes.add(type); @@ -188,13 +187,14 @@ void openApiAndMcpShareClauseSchemas(Class clauseType) throws Exception { var expected = objectMapper.createObjectNode().put("$ref", McpOpenApiSchemaAdapter.componentRef(name)); McpOpenApiSchemaAdapter.bundleOpenApiSchemas(expected, document); + // Normalize numeric examples through the same mapper before comparing schema trees. var actual = objectMapper.readTree(schemaFor(clauseType).toString()); assertEquals(referencedSchema(expected, expected), referencedSchema(actual, actual)); assertEquals(expected.path("$defs"), actual.path("$defs")); } @Test - void filterAnnotationDefinesBothSchemasWithoutWrapperFields() throws Exception { + void filterSchemaAcceptsDocumentObjectsAndNull() throws Exception { var annotation = FilterDefinition.class.getAnnotation(Schema.class); var schema = schemaFor(FilterDefinition.class); var component = referencedSchema(schema, schema); @@ -205,8 +205,6 @@ void filterAnnotationDefinesBothSchemasWithoutWrapperFields() throws Exception { assertEquals( objectMapper.readTree(annotation.examples()[i]), component.path("examples").get(i)); } - assertFalse(component.path("properties").has("json")); - assertFalse(component.path("properties").has("filterClause")); assertSchemaAccepts(schema, objectMapper.readTree("null")); assertSchemaAccepts(schema, objectMapper.readTree("{\"some.path\": {\"$gt\": 40}}")); assertFalse(