diff --git a/build.gradle.kts b/build.gradle.kts index dc5417bb..6811ac6f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -286,8 +286,9 @@ spotless { target("src/**/*.java") // Use Eclipse JDT formatter to avoid google-java-format's incompatibility // with cutting-edge JDKs (e.g., 25) which can trigger NoSuchMethodError - // against internal javac classes. - eclipse() + // against internal javac classes. Override only the annotation-argument + // alignment so multi-arg @Mcp* annotations render one-arg-per-line. + eclipse().configFile("config/spotless/eclipse-java-formatter.properties") removeUnusedImports() trimTrailingWhitespace() endWithNewline() diff --git a/config/spotless/eclipse-java-formatter.properties b/config/spotless/eclipse-java-formatter.properties new file mode 100644 index 00000000..f366fba5 --- /dev/null +++ b/config/spotless/eclipse-java-formatter.properties @@ -0,0 +1,16 @@ +# Eclipse JDT formatter overrides layered on top of the built-in defaults that +# Spotless's eclipse() formatter ships with. Only keys listed here override the +# defaults; everything else stays at Eclipse's stock values. +# +# Why: we want every multi-argument annotation (especially @McpTool, @McpPrompt, +# @McpResource, @McpArg, @McpToolParam) to render with one argument per line so +# the behavior hints (readOnlyHint / destructiveHint / idempotentHint) and the +# descriptive copy stay readable at a glance. The default value here is +# M_COMPACT_SPLIT (16), which only wraps when the line gets too long — leaving +# many annotations collapsed onto a single 200-column line. +# +# Value 48 = M_ONE_PER_LINE_SPLIT (without M_FORCE). The formatter wraps to one +# argument per line only when the line is too long, so single-arg annotations +# like @McpToolParam(description = "...") stay on one line. See: +# org.eclipse.jdt.core.formatter.DefaultCodeFormatterConstants +org.eclipse.jdt.core.formatter.alignment_for_arguments_in_annotation=48 diff --git a/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java index 8e314baf..465c1914 100644 --- a/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java +++ b/src/main/java/org/apache/solr/mcp/server/collection/CollectionService.java @@ -23,10 +23,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.observation.annotation.Observed; +import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; import java.io.IOException; import java.util.ArrayList; import java.util.Date; import java.util.List; +import java.util.Locale; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrServerException; @@ -41,7 +43,10 @@ import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.util.NamedList; import org.apache.solr.mcp.server.config.SolrConfigurationProperties; +import org.apache.solr.mcp.server.util.PromptNames; +import org.springaicommunity.mcp.annotation.McpArg; import org.springaicommunity.mcp.annotation.McpComplete; +import org.springaicommunity.mcp.annotation.McpPrompt; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; @@ -282,24 +287,95 @@ public CollectionService(SolrClient solrClient, ObjectMapper objectMapper) { * * @return JSON string containing the list of collections */ - @McpResource(uri = "solr://collections", name = "solr-collections", description = "List of all Solr collections available in the cluster", mimeType = "application/json") + @PreAuthorize("isAuthenticated()") + @McpResource( + uri = "solr://collections", + name = "solr-collections", + description = "List of all Solr collections available in the cluster", + mimeType = "application/json") public String getCollectionsResource() throws SolrServerException, IOException { return toJson(objectMapper, listCollections()); } + /** Maximum number of completion suggestions returned per request. */ + static final int MAX_COMPLETION_RESULTS = 100; + /** - * MCP Completion endpoint for collection name autocompletion. + * MCP Completion endpoint for the {@code {collection}} segment of the + * {@code solr://{collection}/schema} resource template. * *

- * Provides autocompletion support for the collection parameter in the schema - * resource URI template. Returns all available collection names that MCP - * clients can use to complete the {collection} placeholder. - * - * @return list of available collection names for autocompletion + * Returns collection names that start with the user-supplied prefix + * (case-insensitive). When the prefix is empty all collections are returned, + * subject to {@link #MAX_COMPLETION_RESULTS}. The results are sorted so that + * client UIs see a stable ordering. + * + * @param argument + * the partial value the client is completing; its + * {@link CompleteRequest.CompleteArgument#name() name} must be + * {@code collection} + * @return matching collection names, capped at {@link #MAX_COMPLETION_RESULTS} */ + @PreAuthorize("isAuthenticated()") @McpComplete(uri = "solr://{collection}/schema") - public List completeCollectionForSchema() throws SolrServerException, IOException { - return listCollections(); + public List completeCollection(CompleteRequest.CompleteArgument argument) { + if (argument == null || !"collection".equals(argument.name())) { + return List.of(); + } + String prefix = argument.value() == null ? "" : argument.value().toLowerCase(Locale.ROOT); + try { + return listCollections().stream().filter(c -> c != null && c.toLowerCase(Locale.ROOT).startsWith(prefix)) + .sorted().limit(MAX_COMPLETION_RESULTS).toList(); + } catch (SolrServerException | IOException _) { + return List.of(); + } + } + + /** + * Completion for the {@code collection} argument of the + * {@code search-collection} prompt (defined in {@code SearchService}). + * + *

+ * {@code @McpComplete} registers a handler per {@code (ref/prompt, name)} pair, + * so a prompt that takes a collection argument needs its own handler — the + * resource-template handler on {@link #completeCollection} only matches + * {@code ref/resource}. Each wrapper delegates so all collection-name + * completion shares one implementation and one cap. + */ + @PreAuthorize("isAuthenticated()") + @McpComplete(prompt = PromptNames.SEARCH_COLLECTION) + public List completeSearchCollectionPromptArg(CompleteRequest.CompleteArgument argument) { + return completeCollection(argument); + } + + /** + * Completion for the {@code collection} argument of the {@code index-data} + * prompt. + */ + @PreAuthorize("isAuthenticated()") + @McpComplete(prompt = PromptNames.INDEX_DATA) + public List completeIndexDataPromptArg(CompleteRequest.CompleteArgument argument) { + return completeCollection(argument); + } + + /** + * Completion for the {@code collection} argument of the {@code view-schema} + * prompt. + */ + @PreAuthorize("isAuthenticated()") + @McpComplete(prompt = PromptNames.VIEW_SCHEMA) + public List completeViewSchemaPromptArg(CompleteRequest.CompleteArgument argument) { + return completeCollection(argument); + } + + /** + * Completion for the {@code collection} argument of the {@code design-schema} + * prompt. + */ + @PreAuthorize("isAuthenticated()") + @McpComplete(prompt = PromptNames.DESIGN_SCHEMA) + public List completeDesignSchemaPromptArg(CompleteRequest.CompleteArgument argument) { + return completeCollection(argument); } /** @@ -331,7 +407,10 @@ public List completeCollectionForSchema() throws SolrServerException, IO * @see CollectionAdminRequest.List */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "list-collections", annotations = @McpTool.McpAnnotations(readOnlyHint = true), description = "List solr collections") + @McpTool( + name = "list-collections", + annotations = @McpTool.McpAnnotations(readOnlyHint = true), + description = "List solr collections") public List listCollections() throws SolrServerException, IOException { CollectionAdminRequest.List request = new CollectionAdminRequest.List(); CollectionAdminResponse response = request.process(solrClient); @@ -402,7 +481,10 @@ public List listCollections() throws SolrServerException, IOException { * @see #extractCollectionName(String) */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "get-collection-stats", annotations = @McpTool.McpAnnotations(readOnlyHint = true), description = "Get stats/metrics on a Solr collection") + @McpTool( + name = "get-collection-stats", + annotations = @McpTool.McpAnnotations(readOnlyHint = true), + description = "Get stats/metrics on a Solr collection") public SolrMetrics getCollectionStats( @McpToolParam(description = "Solr collection to get stats/metrics for") String collection) throws SolrServerException, IOException { @@ -944,7 +1026,10 @@ private boolean validateCollectionExists(String collection) throws SolrServerExc * @see SolrPingResponse */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "check-health", annotations = @McpTool.McpAnnotations(readOnlyHint = true), description = "Check health of a Solr collection") + @McpTool( + name = "check-health", + annotations = @McpTool.McpAnnotations(readOnlyHint = true), + description = "Check health of a Solr collection") public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr collection") String collection) { String actualCollection = extractCollectionName(collection); try { @@ -996,13 +1081,20 @@ public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr collection * if there are I/O errors during communication */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "create-collection", annotations = @McpTool.McpAnnotations(destructiveHint = false), description = "Create a new Solr collection. " - + "configSet defaults to _default, numShards and replicationFactor default to 1.") + @McpTool( + name = "create-collection", + annotations = @McpTool.McpAnnotations(destructiveHint = false), + description = "Create a new Solr collection. " + + "configSet defaults to _default, numShards and replicationFactor default to 1.") public CollectionCreationResult createCollection( @McpToolParam(description = "Name of the collection to create") String name, @McpToolParam(description = "Configset name. Defaults to _default.", required = false) String configSet, - @McpToolParam(description = "Number of shards (SolrCloud only). Defaults to 1.", required = false) Integer numShards, - @McpToolParam(description = "Replication factor (SolrCloud only). Defaults to 1.", required = false) Integer replicationFactor) + @McpToolParam( + description = "Number of shards (SolrCloud only). Defaults to 1.", + required = false) Integer numShards, + @McpToolParam( + description = "Replication factor (SolrCloud only). Defaults to 1.", + required = false) Integer replicationFactor) throws SolrServerException, IOException { if (name == null || name.isBlank()) { @@ -1018,4 +1110,78 @@ public CollectionCreationResult createCollection( return new CollectionCreationResult(name, true, "Collection created successfully", new Date()); } + + @PreAuthorize("isAuthenticated()") + + @McpPrompt( + name = PromptNames.EXPLORE_COLLECTIONS, + title = "Explore Solr collections", + description = "Read-only walkthrough: list collections and characterise each by stats and health.") + public String exploreCollectionsPrompt() { + return """ + You are exploring an Apache Solr cluster through MCP tools. Goal: produce a concise, + accurate picture of what already exists. This prompt is read-only; do not create or + modify anything. + + 1. List collections. + - Call `list-collections` to get the full set of collection names. + - If the user mentioned a target collection by name, note whether it appears in the + list. + + 2. Characterize each interesting collection. + - For each collection the user cares about (or a small representative sample if they + did not name one), call `get-collection-stats` to read numDocs, segment counts, and + cache/handler metrics, and `check-health` to confirm the collection responds to a + ping and the doc count is non-zero where expected. + + 3. Summarize. + - Tell the user which collections exist, which look healthy, and which look empty or + stale. + - If the user's intent does not match any existing collection, suggest the + `setup-collection` prompt to create one. + """; + } + + @PreAuthorize("isAuthenticated()") + + @McpPrompt( + name = PromptNames.SETUP_COLLECTION, + title = "Set up a new Solr collection", + description = "Guided workflow: validate a name, pick configset / shards / replication factor, create the collection, and verify it.") + public String setupCollectionPrompt(@McpArg( + name = "name", + description = "Desired collection name. Lowercase letters, digits, underscores, hyphens — no spaces.", + required = true) String name, + @McpArg( + name = "purpose", + description = "Optional one-line description of what the collection is for (used only to ground the conversation).", + required = false) String purpose) { + String purposeLine = (purpose == null || purpose.isBlank()) ? "" : "\nPurpose: %s\n".formatted(purpose.strip()); + return """ + You are setting up a new Solr collection named `%s` through MCP tools.%s + 1. Validate the name. + - Lowercase letters, digits, underscores, hyphens only — no spaces or uppercase. + - Call `list-collections` and confirm `%s` does not already exist. If it does, stop + and tell the user. + + 2. Choose creation parameters. + - Defaults: configset `%s`, %d shard(s), replicationFactor %d. These work for most + single-node and small SolrCloud setups. + - Only override if the user asked: a custom configset for a pre-built schema, more + shards for a large dataset, or higher replicationFactor for redundancy. + + 3. Create. + - Call `create-collection` with `name=%s` and any non-default `configSet`, + `numShards`, `replicationFactor` values. Report the result (success flag + + message). + + 4. Verify. + - Call `list-collections` again; `%s` should appear. + - Call `check-health` on `%s`; it should respond to ping. + + Next step suggestion: define the schema. Use the `design-schema` prompt to design + fields for the dataset the user wants to index. + """.formatted(name, purposeLine, name, DEFAULT_CONFIGSET, DEFAULT_NUM_SHARDS, + DEFAULT_REPLICATION_FACTOR, name, name, name); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java b/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java index c09aae12..cae5ac32 100644 --- a/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java +++ b/src/main/java/org/apache/solr/mcp/server/indexing/IndexingService.java @@ -24,6 +24,10 @@ import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator; +import org.apache.solr.mcp.server.util.PromptNames; +import org.apache.solr.mcp.server.util.PromptText; +import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpPrompt; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; import org.springframework.security.access.prepost.PreAuthorize; @@ -192,7 +196,10 @@ public IndexingService(SolrClient solrClient, IndexingDocumentCreator indexingDo * @see #indexDocuments(String, List) */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "index-json-documents", annotations = @McpTool.McpAnnotations(idempotentHint = true), description = "Index documents from json String into Solr collection") + @McpTool( + name = "index-json-documents", + annotations = @McpTool.McpAnnotations(idempotentHint = true), + description = "Index documents from json String into Solr collection") public String indexJsonDocuments(@McpToolParam(description = "Solr collection to index into") String collection, @McpToolParam(description = "JSON string containing documents to index") String json) throws IOException, SolrServerException { @@ -260,7 +267,10 @@ public String indexJsonDocuments(@McpToolParam(description = "Solr collection to * @see #indexDocuments(String, List) */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "index-csv-documents", annotations = @McpTool.McpAnnotations(idempotentHint = true), description = "Index documents from CSV string into Solr collection") + @McpTool( + name = "index-csv-documents", + annotations = @McpTool.McpAnnotations(idempotentHint = true), + description = "Index documents from CSV string into Solr collection") public String indexCsvDocuments(@McpToolParam(description = "Solr collection to index into") String collection, @McpToolParam(description = "CSV string containing documents to index") String csv) throws IOException, SolrServerException { @@ -352,7 +362,10 @@ public String indexCsvDocuments(@McpToolParam(description = "Solr collection to * @see #indexDocuments(String, List) */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "index-xml-documents", annotations = @McpTool.McpAnnotations(idempotentHint = true), description = "Index documents from XML string into Solr collection") + @McpTool( + name = "index-xml-documents", + annotations = @McpTool.McpAnnotations(idempotentHint = true), + description = "Index documents from XML string into Solr collection") public String indexXmlDocuments(@McpToolParam(description = "Solr collection to index into") String collection, @McpToolParam(description = "XML string containing documents to index") String xml) throws ParserConfigurationException, SAXException, IOException, SolrServerException { @@ -456,4 +469,76 @@ public int indexDocuments(String collection, List documents) solrClient.commit(collection); return successCount; } + + /** + * Maps an input-format keyword to the MCP tool and payload parameter for that + * format. + */ + private record IndexTool(String name, String paramName) { + } + + private static IndexTool resolveIndexTool(String format) { + String normalized = (format == null) ? "" : format.trim().toLowerCase(); + return switch (normalized) { + case "json" -> new IndexTool("index-json-documents", "json"); + case "csv" -> new IndexTool("index-csv-documents", "csv"); + case "xml" -> new IndexTool("index-xml-documents", "xml"); + default -> throw new IllegalArgumentException("format must be one of json/csv/xml, got: " + format); + }; + } + + @PreAuthorize("isAuthenticated()") + + @McpPrompt( + name = PromptNames.INDEX_DATA, + title = "Index documents into a Solr collection", + description = "Guides the assistant through verifying the target schema, picking the right indexing tool for the input format, and confirming the result.") + public String indexDataPrompt( + @McpArg( + name = "collection", + description = "Target Solr collection name", + required = true) String collection, + @McpArg( + name = "format", + description = "Document format: 'json', 'csv', or 'xml'", + required = true) String format, + @McpArg( + name = "sample", + description = "Optional small sample of the input document(s) to ground field-shape decisions", + required = false) String sample) { + IndexTool indexTool = resolveIndexTool(format); + String sampleSection = PromptText.optionalCodeBlock(sample, "Sample input:", + "No sample was provided. If the user has not pasted the documents yet, ask for them (or a representative subset) before indexing."); + return """ + You are indexing %s data into collection `%s` via MCP tools. Work incrementally and + verify after each step. + + 1. Confirm the schema is ready. + - Call `get-schema` on `%s`. Confirm the fields the input references exist with + compatible types. If fields are missing or typed wrong, pause and run the + `design-schema` prompt to add them — indexing into a collection without the right + fields either fails or silently falls back to schemaless behavior, which can + pollute the configset. + + 2. Inspect the input. + %s + + 3. Index the documents. + - Call `%s` with `collection=%s` and `%s=`. + - The tool batches internally and commits at the end. The return value is the count + of successfully indexed documents. + - On error, read the message carefully: an "unknown field" error means the schema is + missing a field — go back to step 1 and run `design-schema`. A parse error means + the input format does not match the chosen tool — fix the payload and retry. + + 4. Verify the count. + - Call `check-health` on `%s` and confirm the reported doc count increased by the + expected amount, OR call `search` with `query=*:*` and `rows=0` and read + `numFound`. + + Next step suggestion: once data is indexed, the `search-collection` prompt drives + searching it. + """.formatted(indexTool.paramName(), collection, collection, sampleSection, indexTool.name(), + collection, indexTool.paramName(), collection); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java b/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java index 388269d6..8e0549a1 100644 --- a/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java +++ b/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java @@ -17,6 +17,7 @@ package org.apache.solr.mcp.server.schema; import static org.apache.solr.mcp.server.util.JsonUtils.toJson; +import static org.apache.solr.mcp.server.util.PromptText.optionalCodeBlock; import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.observation.annotation.Observed; @@ -31,6 +32,9 @@ import org.apache.solr.client.solrj.request.schema.FieldTypeDefinition; import org.apache.solr.client.solrj.request.schema.SchemaRequest; import org.apache.solr.client.solrj.response.schema.SchemaRepresentation; +import org.apache.solr.mcp.server.util.PromptNames; +import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpPrompt; import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; @@ -171,7 +175,12 @@ public SchemaService(SolrClient solrClient, ObjectMapper objectMapper) { * the name of the collection to retrieve schema for * @return JSON string containing the schema representation */ - @McpResource(uri = "solr://{collection}/schema", name = "solr-collection-schema", description = "Schema definition for a Solr collection including fields, field types, and copy fields", mimeType = "application/json") + @PreAuthorize("isAuthenticated()") + @McpResource( + uri = "solr://{collection}/schema", + name = "solr-collection-schema", + description = "Schema definition for a Solr collection including fields, field types, and copy fields", + mimeType = "application/json") public String getSchemaResource(String collection) { try { return toJson(objectMapper, getSchema(collection)); @@ -260,25 +269,32 @@ public String getSchemaResource(String collection) { * @see org.apache.solr.client.solrj.response.schema.SchemaResponse */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "get-schema", annotations = @McpTool.McpAnnotations(readOnlyHint = true), description = "Get schema for a Solr collection") + @McpTool( + name = "get-schema", + annotations = @McpTool.McpAnnotations(readOnlyHint = true), + description = "Get schema for a Solr collection") public SchemaRepresentation getSchema(String collection) throws Exception { SchemaRequest schemaRequest = new SchemaRequest(); return schemaRequest.process(solrClient, collection).getSchemaRepresentation(); } @PreAuthorize("isAuthenticated()") - @McpTool(name = "add-fields", annotations = @McpTool.McpAnnotations(destructiveHint = false), description = "Add one or more fields to a Solr collection schema. " - + "Call get-schema first to inspect existing field configuration before adding. " - + "Each field map follows the Solr Schema API add-field shape: required keys " - + "'name' and 'type', plus optional 'stored', 'indexed', 'docValues', " - + "'multiValued', 'required', 'omitNorms', etc. " - + "Example: {\"name\":\"platform\",\"type\":\"string\",\"stored\":true,\"indexed\":true,\"docValues\":true}. " - + "Use 'strings' (not 'string') for multi-valued string fields. " - + "Note: this only adds new fields; existing fields cannot be modified. " - + "Solr's Schema API is transactional — if any command in the batch fails, " - + "none are applied. On failure, fix the invalid field(s) and retry the whole batch.") + @McpTool( + name = "add-fields", + annotations = @McpTool.McpAnnotations(destructiveHint = false), + description = "Add one or more fields to a Solr collection schema. " + + "Call get-schema first to inspect existing field configuration before adding. " + + "Each field map follows the Solr Schema API add-field shape: required keys " + + "'name' and 'type', plus optional 'stored', 'indexed', 'docValues', " + + "'multiValued', 'required', 'omitNorms', etc. " + + "Example: {\"name\":\"platform\",\"type\":\"string\",\"stored\":true,\"indexed\":true,\"docValues\":true}. " + + "Use 'strings' (not 'string') for multi-valued string fields. " + + "Note: this only adds new fields; existing fields cannot be modified. " + + "Solr's Schema API is transactional — if any command in the batch fails, " + + "none are applied. On failure, fix the invalid field(s) and retry the whole batch.") public SchemaUpdateResult addFields(@McpToolParam(description = "Solr collection name") String collection, - @McpToolParam(description = "List of field definitions (Solr add-field JSON shape)") List> fields) + @McpToolParam( + description = "List of field definitions (Solr add-field JSON shape)") List> fields) throws SolrServerException, IOException { requireCollection(collection); requireNonEmpty(fields, "fields"); @@ -295,20 +311,25 @@ public SchemaUpdateResult addFields(@McpToolParam(description = "Solr collection } @PreAuthorize("isAuthenticated()") - @McpTool(name = "add-field-types", annotations = @McpTool.McpAnnotations(destructiveHint = false), description = "Add one or more field types to a Solr collection schema. " - + "Call get-schema first to inspect existing field types before adding. " - + "Each map follows the Solr Schema API add-field-type shape: required keys " - + "'name' and 'class', optional 'analyzer' (or 'indexAnalyzer'+'queryAnalyzer'), " - + "and class-specific attributes. " + "Common recipes: " - + "(1) case-insensitive exact match: class=solr.TextField with analyzer " - + "{tokenizer:{class:solr.KeywordTokenizerFactory}, filters:[{class:solr.LowerCaseFilterFactory}]}; " - + "(2) dense vector for semantic search: class=solr.DenseVectorField with " - + "vectorDimension, similarityFunction (cosine/dot_product/euclidean), and knnAlgorithm=hnsw; " - + "(3) autocomplete: class=solr.TextField with separate indexAnalyzer using EdgeNGramFilterFactory " - + "and queryAnalyzer without it. " + "After adding a type, use add-fields to create fields of that type. " - + "Solr's Schema API is transactional — if any command in the batch fails, none are applied.") + @McpTool( + name = "add-field-types", + annotations = @McpTool.McpAnnotations(destructiveHint = false), + description = "Add one or more field types to a Solr collection schema. " + + "Call get-schema first to inspect existing field types before adding. " + + "Each map follows the Solr Schema API add-field-type shape: required keys " + + "'name' and 'class', optional 'analyzer' (or 'indexAnalyzer'+'queryAnalyzer'), " + + "and class-specific attributes. " + "Common recipes: " + + "(1) case-insensitive exact match: class=solr.TextField with analyzer " + + "{tokenizer:{class:solr.KeywordTokenizerFactory}, filters:[{class:solr.LowerCaseFilterFactory}]}; " + + "(2) dense vector for semantic search: class=solr.DenseVectorField with " + + "vectorDimension, similarityFunction (cosine/dot_product/euclidean), and knnAlgorithm=hnsw; " + + "(3) autocomplete: class=solr.TextField with separate indexAnalyzer using EdgeNGramFilterFactory " + + "and queryAnalyzer without it. " + + "After adding a type, use add-fields to create fields of that type. " + + "Solr's Schema API is transactional — if any command in the batch fails, none are applied.") public SchemaUpdateResult addFieldTypes(@McpToolParam(description = "Solr collection name") String collection, - @McpToolParam(description = "List of field type definitions (Solr add-field-type JSON shape)") List> fieldTypes) + @McpToolParam( + description = "List of field type definitions (Solr add-field-type JSON shape)") List> fieldTypes) throws SolrServerException, IOException { requireCollection(collection); requireNonEmpty(fieldTypes, "fieldTypes"); @@ -417,4 +438,121 @@ private static void requireNonEmpty(List list, String name) { throw new IllegalArgumentException(name + " must not be empty"); } } + + @PreAuthorize("isAuthenticated()") + + @McpPrompt( + name = PromptNames.VIEW_SCHEMA, + title = "View a Solr collection schema", + description = "Read-only walkthrough: fetch the schema and summarize fields, types, dynamic fields, copy fields, and the unique key.") + public String viewSchemaPrompt(@McpArg( + name = "collection", + description = "Target Solr collection name", + required = true) String collection) { + return """ + You are inspecting the schema of Solr collection `%s`. This prompt is read-only; do not + add or modify any fields. + + 1. Fetch the schema. + - Call `get-schema` on `%s`. Capture the full response (fields, fieldTypes, + dynamicFields, copyFields, uniqueKey). + + 2. Summarize fields. + - Total field count. + - Group fields by type (e.g. how many `text_general`, `string`, `strings`, `pint`, + `pdouble`, `pdate`, etc.). + - Flag which fields are `indexed=true` (searchable / filterable), `stored=true` + (retrievable in responses), `docValues=true` (sortable / facetable / range- + filterable), and `multiValued=true`. + - Call out the `uniqueKey` field — this is the primary identifier. + + 3. Summarize dynamic fields and copy fields. + - List dynamic-field patterns (e.g. `*_s`, `*_txt`) and their types — these accept + fields whose names are not predeclared. + - List copyField rules (source → destination) — these duplicate content into + aggregator fields, often used for catch-all search fields. + + 4. Surface anything unusual. + - Custom field types (analyzers, tokenizers, filters). + - Required fields (`required=true`) the indexer must always populate. + - Fields that are indexed but not stored (searchable but not returnable) or vice + versa. + + Next step suggestion: if the schema is missing fields the user needs, the + `design-schema` prompt drives the additive workflow. + """.formatted(collection, collection); + } + + @PreAuthorize("isAuthenticated()") + + @McpPrompt( + name = PromptNames.DESIGN_SCHEMA, + title = "Design a Solr schema for a dataset", + description = "Guides the assistant through inspecting an existing Solr schema, choosing appropriate field types, and applying additive schema changes via the Schema API.") + public String designSchemaPrompt( + @McpArg( + name = "collection", + description = "Target Solr collection name", + required = true) String collection, + @McpArg( + name = "datasetDescription", + description = "Free-text description of the data being indexed (entity, key attributes, expected query patterns)", + required = true) String datasetDescription, + @McpArg( + name = "sampleDocument", + description = "Optional single document in JSON to ground field inference", + required = false) String sampleDocument) { + String sampleSection = optionalCodeBlock(sampleDocument, + "A sample document was provided. Use it as ground truth for field names and value\n shapes:", + "No sample document was provided; ask the user for one if the dataset description leaves field types ambiguous."); + return """ + You are designing a Solr schema for collection `%s`. Dataset: + + %s + + Follow this workflow. The Solr Schema API is transactional per batch — if any command in + a batch fails, none are applied — so plan carefully before each `add-fields` or + `add-field-types` call. + + 1. Inspect the current schema. + - Call `get-schema` on `%s`. Note which fields already exist and which field types + (e.g. `text_general`, `string`, `strings`, `pint`, `pdouble`, `pdate`) are defined. + Existing fields cannot be modified, only added to. + + 2. Anchor on the dataset. + %s + + 3. Map dataset attributes to Solr field types. + - Free-text the user will search on: `text_general` (tokenized) for descriptions, + titles, bodies. + - Exact-match facets / filters: `string` for single-valued, `strings` for multi-valued + (categories, tags, genres). + - Numerics: `pint`, `plong`, `pfloat`, `pdouble`. Add `docValues=true` if you need to + sort, facet, or range-filter on the field. + - Dates: `pdate`. + - Identifiers: `string` with `docValues=true`. + - Semantic / vector search: a custom `DenseVectorField` type via `add-field-types` + (specify `vectorDimension`, `similarityFunction`, `knnAlgorithm=hnsw`), then a field + of that type via `add-fields`. + - Case-insensitive exact match or autocomplete: a custom `solr.TextField` type with a + `KeywordTokenizerFactory` + `LowerCaseFilterFactory` analyzer (or split + index/query analyzers with `EdgeNGramFilterFactory` for autocomplete). + + 4. Add field types first, then fields. + - If you need any custom field types, call `add-field-types` with the full batch in + one call. On failure, inspect the error, fix the offending entry, and retry the + entire batch. + - Then call `add-fields` with the full batch of new fields. Each field map must + include `name` and `type`; recommended extras are `stored`, `indexed`, + `docValues`, `multiValued`, `required`. + + 5. Verify. + - Call `get-schema` again and confirm every desired field and type is present. If the + collection shares a configset with other collections, some fields may already + exist from earlier work — that is fine; only add the gap. + + Next step suggestion: once the schema is in place, the `index-data` prompt drives + indexing documents into the collection. + """.formatted(collection, datasetDescription, collection, sampleSection); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java index e30f0124..3fc0a393 100644 --- a/src/main/java/org/apache/solr/mcp/server/search/SearchService.java +++ b/src/main/java/org/apache/solr/mcp/server/search/SearchService.java @@ -30,6 +30,9 @@ import org.apache.solr.common.SolrDocument; import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.params.FacetParams; +import org.apache.solr.mcp.server.util.PromptNames; +import org.springaicommunity.mcp.annotation.McpArg; +import org.springaicommunity.mcp.annotation.McpPrompt; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; import org.springframework.security.access.prepost.PreAuthorize; @@ -215,7 +218,11 @@ private static Map> getFacets(QueryResponse queryRespo * If there's an I/O error */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "search", annotations = @McpTool.McpAnnotations(readOnlyHint = true), description = """ + // @formatter:off — keep this @McpTool wrapped like the others; the text block disguises the real line length. + @McpTool( + name = "search", + annotations = @McpTool.McpAnnotations(readOnlyHint = true), + description = """ Search specified Solr collection with query, optional filters, facets, sorting, and pagination. Note that solr has dynamic fields where name of field in schema may end with suffixes _s: Represents a string field, used for exact string matching. @@ -241,8 +248,11 @@ private static Map> getFacets(QueryResponse queryRespo "_root_":"0553579908" } """) + // @formatter:on public SearchResponse search(@McpToolParam(description = "Solr collection to query") String collection, - @McpToolParam(description = "Solr q parameter. If none specified defaults to \"*:*\"", required = false) String query, + @McpToolParam( + description = "Solr q parameter. If none specified defaults to \"*:*\"", + required = false) String query, @McpToolParam(description = "Solr fq parameter", required = false) List filterQueries, @McpToolParam(description = "Solr facet fields", required = false) List facetFields, @McpToolParam(description = "Solr sort parameter", required = false) List> sortClauses, @@ -298,4 +308,69 @@ public SearchResponse search(@McpToolParam(description = "Solr collection to que return new SearchResponse(documents.getNumFound(), documents.getStart(), documents.getMaxScore(), docs, facets); } + + @PreAuthorize("isAuthenticated()") + + @McpPrompt( + name = PromptNames.SEARCH_COLLECTION, + title = "Search a Solr collection from a natural-language question", + description = "Guides the assistant through inspecting the schema, translating a user question into a Solr query, running the search, and refining the result.") + public String searchCollectionPrompt( + @McpArg( + name = "collection", + description = "Target Solr collection name", + required = true) String collection, + @McpArg( + name = "question", + description = "The user's natural-language search question or information need", + required = true) String question) { + return """ + You are searching collection `%s` to answer: + + %s + + Work incrementally — Solr query design is sensitive to the schema, so anchor on the + schema before constructing queries. + + 1. Learn the schema. + - Call `get-schema` on `%s`. Identify which fields are searchable text + (`text_general` or similar tokenized types), which are filterable exact-match + (`string`/`strings`), which are numeric/date ranges, and which have `docValues` + (needed for faceting and sorting). + + 2. Translate the question into Solr query parts. + - Pick the most informative tokens from the question. Map them to fields: + * Free-text concepts → `q` against tokenized fields, e.g. + `description:apocalyptic` or `title:Solr` or a multi-field edismax-style + construction `(title:foo OR description:foo)`. + * Exact-match attributes → `filterQueries` against `string`/`strings`, e.g. + `platform:Netflix`, `genres:Sci-Fi`. Quote multi-word values: + `platform:"Amazon Prime Video"`. + * Numeric/date constraints → range syntax in `filterQueries`, e.g. + `release_year:[2010 TO 2020]`. + - Start with `*:*` as `q` if the question is purely filter-driven; let + `filterQueries` do the work. + + 3. Run the search. + - Call `search` with `collection=%s` and the chosen `query` plus optional + `filterQueries`, `facetFields`, `sortFields`, `start`, `rows`. Set `rows=10` for a + focused look or `rows=0` if you only need counts / facets. + + 4. Interpret and refine. + - Check `numFound` first. + * Zero results: relax filters one at a time, broaden the query (try a more + general term), or fall back to `q=*:*` with the strongest filter to confirm the + collection contains relevant data. + * Many results: add a `filterQueries` constraint to narrow, or pass + `facetFields` on a relevant `string`/`strings` field to surface the distribution + and pick a sharper filter. + - Inspect `documents` for the actual content. The response includes `maxScore` when + the query is not `*:*`; use it as a relative confidence signal across queries. + + 5. Summarize. + - Answer the user's question grounded in the documents found, citing concrete field + values (title, id, etc.). If the search did not produce a clear answer, surface + that explicitly rather than guessing. + """.formatted(collection, question, collection, collection); + } } diff --git a/src/main/java/org/apache/solr/mcp/server/security/HttpSecurityConfiguration.java b/src/main/java/org/apache/solr/mcp/server/security/HttpSecurityConfiguration.java index 86f7bf26..a8f0905d 100644 --- a/src/main/java/org/apache/solr/mcp/server/security/HttpSecurityConfiguration.java +++ b/src/main/java/org/apache/solr/mcp/server/security/HttpSecurityConfiguration.java @@ -27,6 +27,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.util.StringUtils; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -45,7 +46,7 @@ class HttpSecurityConfiguration { @Bean @ConditionalOnProperty(name = "http.security.enabled", havingValue = "true", matchIfMissing = true) SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http.authorizeHttpRequests(auth -> { + http.authorizeHttpRequests(auth -> { // Liveness/readiness probes need anonymous access for load // balancers and orchestrators. All other actuator endpoints // (loggers, sbom, metrics, prometheus, info) require auth so @@ -59,21 +60,33 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { // "secured tools" sample pattern. auth.requestMatchers("/mcp").permitAll(); auth.anyRequest().authenticated(); - }) - // Configure OAuth2 on the MCP server. - // - // resourcePath: declares "/mcp" as the canonical resource indicator - // for OAuth 2.0 Protected Resource Metadata (RFC 9728), which is what - // MCP clients use to discover the authorization server. - // - // validateAudienceClaim: per the MCP Authorization specification, MCP - // servers MUST validate that tokens were specifically issued for them. - // The audience is matched against the resource indicator (RFC 8707) - // configured above. The IdP must populate the JWT "aud" claim - // accordingly — see docs/security/http.md for IdP configuration notes. - .with(McpServerOAuth2Configurer.mcpServerOAuth2(), - mcpAuthorization -> mcpAuthorization.authorizationServer(issuerUrl).resourcePath("/mcp") - .validateAudienceClaim(true)) + }); + // Configure OAuth2 on the MCP server. + // + // Only wired when an issuer URL is actually supplied — + // McpServerOAuth2Configurer + // builds a NimbusJwtDecoder eagerly during init() and that builder requires a + // non-blank issuer. In native (AOT) builds the secured filter-chain bean is + // baked in regardless of the runtime http.security.enabled value, so the only + // way to let an unconfigured native-http image start (e.g. CI smoke test) is to + // gate the OAuth2 wiring at runtime here. With no issuer set, every non- + // permitAll() endpoint still falls through to Spring Security's default 401/403 + // — the chain is locked down, just without a bearer-token validator. + // + // resourcePath: declares "/mcp" as the canonical resource indicator + // for OAuth 2.0 Protected Resource Metadata (RFC 9728), which is what + // MCP clients use to discover the authorization server. + // + // validateAudienceClaim: per the MCP Authorization specification, MCP + // servers MUST validate that tokens were specifically issued for them. + // The audience is matched against the resource indicator (RFC 8707) + // configured above. The IdP must populate the JWT "aud" claim + // accordingly — see docs/security/http.md for IdP configuration notes. + if (StringUtils.hasText(issuerUrl)) { + http.with(McpServerOAuth2Configurer.mcpServerOAuth2(), mcpAuthorization -> mcpAuthorization + .authorizationServer(issuerUrl).resourcePath("/mcp").validateAudienceClaim(true)); + } + return http // MCP inspector .cors(cors -> cors.configurationSource(corsConfigurationSource())).csrf(CsrfConfigurer::disable) .build(); diff --git a/src/main/java/org/apache/solr/mcp/server/util/PromptNames.java b/src/main/java/org/apache/solr/mcp/server/util/PromptNames.java new file mode 100644 index 00000000..09c05938 --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/util/PromptNames.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.util; + +/** + * Canonical names of every {@code @McpPrompt} exposed by this server. + * + *

+ * MCP's {@code completion/complete} protocol matches a {@code PromptReference} + * by string name against the registered completion handlers, so the strings in + * {@code @McpPrompt(name = ...)} and {@code @McpComplete(prompt = ...)} must be + * byte-identical. The Spring AI registry does no cross-checking — a typo on + * either side compiles cleanly and only surfaces at runtime as + * {@code -32602: AsyncCompletionSpecification not found}. + * + *

+ * Routing both annotations through a single constant turns that class of bug + * into a compile error: deleting or renaming a prompt forces the corresponding + * {@code @McpComplete} site to update or fail to compile. + */ +public final class PromptNames { + + /** {@code @McpPrompt} on {@code CollectionService#exploreCollectionsPrompt}. */ + public static final String EXPLORE_COLLECTIONS = "explore-collections"; + + /** {@code @McpPrompt} on {@code CollectionService#setupCollectionPrompt}. */ + public static final String SETUP_COLLECTION = "setup-collection"; + + /** {@code @McpPrompt} on {@code SearchService#searchCollectionPrompt}. */ + public static final String SEARCH_COLLECTION = "search-collection"; + + /** {@code @McpPrompt} on {@code IndexingService#indexDataPrompt}. */ + public static final String INDEX_DATA = "index-data"; + + /** {@code @McpPrompt} on {@code SchemaService#viewSchemaPrompt}. */ + public static final String VIEW_SCHEMA = "view-schema"; + + /** {@code @McpPrompt} on {@code SchemaService#designSchemaPrompt}. */ + public static final String DESIGN_SCHEMA = "design-schema"; + + private PromptNames() { + // Constants holder - prevent instantiation + } +} diff --git a/src/main/java/org/apache/solr/mcp/server/util/PromptText.java b/src/main/java/org/apache/solr/mcp/server/util/PromptText.java new file mode 100644 index 00000000..7fc28c42 --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/util/PromptText.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.server.util; + +/** Shared text-shaping helpers for {@code @McpPrompt} method bodies. */ +public final class PromptText { + + private PromptText() { + // Utility class - prevent instantiation + } + + /** + * Renders an "optional sample" section of a prompt body. When {@code content} + * is supplied, emits a bulleted line introducing the sample and an indented + * fenced code block holding it; when absent or blank, emits a different + * bulleted line asking the LLM to request the sample. + * + * @param content + * the raw user-supplied content (may be {@code null} or blank) + * @param presentLead + * the lead text for the "content provided" case + * @param absentLine + * the full bullet line for the "no content" case + * @return a string ready to be interpolated into a Java text block + */ + public static String optionalCodeBlock(String content, String presentLead, String absentLine) { + if (content == null || content.isBlank()) { + return " - " + absentLine; + } + String indented = content.strip().replace("\n", "\n "); + return " - " + presentLead + "\n\n ```\n " + indented + "\n ```"; + } +} diff --git a/src/main/resources/application-http.properties b/src/main/resources/application-http.properties index 6a613b5d..24c6131c 100644 --- a/src/main/resources/application-http.properties +++ b/src/main/resources/application-http.properties @@ -22,7 +22,12 @@ spring.docker.compose.enabled=true # `Included Custom Audience` to the MCP server URL (Keycloak does # not yet honor RFC 8707 `resource=` natively, see # docs/security/http.md). -spring.security.oauth2.resourceserver.jwt.issuer-uri=${OAUTH2_ISSUER_URI:https://your-auth0-domain.auth0.com/} +# Leave empty when no IdP is configured. HttpSecurityConfiguration treats an +# empty value as "no OAuth2 wiring" — with http.security.enabled=true (the +# default) the filter chain still gates every non-permitAll endpoint, so +# unconfigured deployments fall back to 401/403 rather than crashing on a +# placeholder URL during NimbusJwtDecoder initialization. +spring.security.oauth2.resourceserver.jwt.issuer-uri=${OAUTH2_ISSUER_URI:} # Security toggle - HTTP mode is secured by default. Set HTTP_SECURITY_ENABLED=false # to bypass OAuth2 authentication for local development only. Disabling security # in any environment reachable from the network is unsafe; the MCP Authorization diff --git a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java index 486417c9..71db0ccc 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTest.java @@ -31,8 +31,9 @@ * the full application with a real Solr container and exercises all MCP tools * via an HTTP transport. */ -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {"http.security.enabled=false", - "spring.docker.compose.enabled=false"}) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = {"http.security.enabled=false", "spring.docker.compose.enabled=false"}) @ActiveProfiles("http") @Import(TestcontainersConfiguration.class) @Tag("integration") diff --git a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java index 50fee685..2f617ec1 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java @@ -23,6 +23,14 @@ import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; +import io.modelcontextprotocol.spec.McpSchema.CompleteResult; +import io.modelcontextprotocol.spec.McpSchema.Content; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.PromptReference; +import io.modelcontextprotocol.spec.McpSchema.ResourceReference; import io.modelcontextprotocol.spec.McpSchema.TextContent; import io.modelcontextprotocol.spec.McpSchema.Tool; import java.io.InputStream; @@ -619,6 +627,183 @@ void getShowsCollectionStats() throws Exception { "Stats should report " + SHOWS_DOC_COUNT + " docs somewhere in the payload: " + text); } + // ===== Prompt workflow (orders 28–34) ===== + // Verifies the six @McpPrompt endpoints are discovered, listable, and return + // non-empty guidance referencing the right tools when fetched. Prompts are + // LLM-facing instruction templates — the framework wraps the String returned by + // each @McpPrompt method as a single user-role PromptMessage. + + @Test + @Order(28) + void listPromptsReturnsExpectedPrompts() { + var promptsResult = mcpClient.listPrompts(); + assertNotNull(promptsResult); + List promptNames = promptsResult.prompts().stream().map(p -> p.name()).toList(); + + for (String expected : List.of("explore-collections", "setup-collection", "view-schema", "design-schema", + "index-data", "search-collection")) { + assertTrue(promptNames.contains(expected), "Should expose " + expected + " prompt: " + promptNames); + } + } + + @Test + @Order(29) + void getExploreCollectionsPromptReturnsGuidance() { + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest("explore-collections", Map.of())); + + String text = extractFirstMessageText(result); + assertTrue(text.contains("list-collections"), "Prompt body should reference list-collections: " + text); + assertTrue(text.contains("get-collection-stats"), "Prompt body should reference get-collection-stats: " + text); + assertFalse(text.contains("create-collection"), + "Explore prompt is read-only; should not reference create-collection: " + text); + } + + @Test + @Order(30) + void getSetupCollectionPromptReturnsGuidance() { + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest("setup-collection", + Map.of("name", "scratch_collection", "purpose", "Testing setup-collection prompt"))); + + String text = extractFirstMessageText(result); + assertTrue(text.contains("scratch_collection"), "Prompt body should embed the collection name: " + text); + assertTrue(text.contains("Testing setup-collection prompt"), "Prompt body should embed the purpose: " + text); + assertTrue(text.contains("create-collection"), "Prompt body should reference create-collection tool: " + text); + assertTrue(text.contains("_default"), "Prompt body should mention the default configset: " + text); + } + + @Test + @Order(31) + void getViewSchemaPromptReturnsGuidance() { + GetPromptResult result = mcpClient + .getPrompt(new GetPromptRequest("view-schema", Map.of("collection", SHOWS_COLLECTION))); + + String text = extractFirstMessageText(result); + assertTrue(text.contains(SHOWS_COLLECTION), "Prompt body should embed the collection name: " + text); + assertTrue(text.contains("get-schema"), "Prompt body should reference get-schema: " + text); + assertFalse(text.contains("add-fields"), "View prompt is read-only; should not reference add-fields: " + text); + } + + @Test + @Order(32) + void getDesignSchemaPromptReturnsGuidance() { + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest("design-schema", + Map.of("collection", SHOWS_COLLECTION, "datasetDescription", "TV shows with title, platform, genres"))); + + String text = extractFirstMessageText(result); + assertTrue(text.contains(SHOWS_COLLECTION), "Prompt body should embed the collection name: " + text); + assertTrue(text.contains("add-fields"), "Prompt body should reference add-fields: " + text); + assertTrue(text.contains("add-field-types"), "Prompt body should reference add-field-types: " + text); + } + + @Test + @Order(33) + void getIndexDataPromptReturnsGuidance() { + GetPromptResult result = mcpClient.getPrompt( + new GetPromptRequest("index-data", Map.of("collection", SHOWS_COLLECTION, "format", "json"))); + + String text = extractFirstMessageText(result); + assertTrue(text.contains("index-json-documents"), + "Prompt body should select index-json-documents for json format: " + text); + assertTrue(text.contains("get-schema"), "Prompt body should reference get-schema verification: " + text); + } + + @Test + @Order(34) + void getSearchCollectionPromptReturnsGuidance() { + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest("search-collection", + Map.of("collection", SHOWS_COLLECTION, "question", "What sci-fi shows are on Netflix?"))); + + String text = extractFirstMessageText(result); + assertTrue(text.contains(SHOWS_COLLECTION), "Prompt body should embed the collection name: " + text); + assertTrue(text.contains("What sci-fi shows are on Netflix?"), + "Prompt body should embed the user question: " + text); + assertTrue(text.contains("filterQueries"), "Prompt body should explain filterQueries: " + text); + } + + // ===== Collection-completion workflow (orders 35–36) ===== + + @Test + @Order(35) + void completeCollection_ReturnsCreatedCollection() { + ResourceReference ref = new ResourceReference("solr://{collection}/schema"); + CompleteRequest request = new CompleteRequest(ref, + new CompleteRequest.CompleteArgument("collection", COLLECTION.substring(0, 3))); + + CompleteResult result = mcpClient.completeCompletion(request); + + assertNotNull(result); + assertNotNull(result.completion()); + List values = result.completion().values(); + assertNotNull(values); + assertTrue(values.contains(COLLECTION), + "Completion should include the previously created collection: " + values); + } + + @Test + @Order(36) + void completeCollection_NoMatchesReturnsEmptyValues() { + ResourceReference ref = new ResourceReference("solr://{collection}/schema"); + CompleteRequest request = new CompleteRequest(ref, + new CompleteRequest.CompleteArgument("collection", "no-such-prefix-zzz")); + + CompleteResult result = mcpClient.completeCompletion(request); + + assertNotNull(result); + assertNotNull(result.completion()); + assertTrue(result.completion().values().isEmpty(), + "No collections should match an unknown prefix: " + result.completion().values()); + } + + // ===== Prompt-arg completion (orders 37–38) ===== + // + // Regression coverage for the MCP Inspector bug where opening a prompt that + // takes a `collection` argument raised "-32602: AsyncCompletionSpecification + // not found: PromptReference[...]". The fix registers @McpComplete(prompt=...) + // handlers in addition to the existing resource-template handler; without + // them, completion/complete with a ref/prompt reference has no binding. + + @Test + @Order(37) + void completePromptArg_SearchCollection_ReturnsCreatedCollection() { + PromptReference ref = new PromptReference("search-collection"); + CompleteRequest request = new CompleteRequest(ref, + new CompleteRequest.CompleteArgument("collection", COLLECTION.substring(0, 3))); + + CompleteResult result = mcpClient.completeCompletion(request); + + assertNotNull(result); + assertNotNull(result.completion()); + List values = result.completion().values(); + assertNotNull(values); + assertTrue(values.contains(COLLECTION), + "Prompt completion should include the previously created collection: " + values); + } + + @Test + @Order(38) + void completePromptArg_ViewSchema_ReturnsCreatedCollection() { + PromptReference ref = new PromptReference("view-schema"); + CompleteRequest request = new CompleteRequest(ref, + new CompleteRequest.CompleteArgument("collection", COLLECTION.substring(0, 3))); + + CompleteResult result = mcpClient.completeCompletion(request); + + assertNotNull(result); + assertNotNull(result.completion()); + assertTrue(result.completion().values().contains(COLLECTION), + "view-schema prompt completion should resolve the created collection: " + result.completion().values()); + } + + private static String extractFirstMessageText(GetPromptResult result) { + List messages = result.messages(); + assertFalse(messages.isEmpty(), "messages must not be empty"); + Content content = messages.getFirst().content(); + assertInstanceOf(TextContent.class, content, "first prompt message content should be TextContent"); + String text = ((TextContent) content).text(); + assertFalse(text.isBlank(), "prompt message text should not be blank"); + return text; + } + private static String loadClasspathResource(String resourcePath) throws Exception { try (InputStream in = McpClientIntegrationTestBase.class.getResourceAsStream(resourcePath)) { Objects.requireNonNull(in, "Classpath resource not found: " + resourcePath); @@ -629,15 +814,16 @@ private static String loadClasspathResource(String resourcePath) throws Exceptio protected static String extractText(CallToolResult result) { assertNotNull(result.content(), "Result content should not be null"); assertFalse(result.content().isEmpty(), "Result content should not be empty"); - assertInstanceOf(TextContent.class, result.content().get(0), "Content should be TextContent"); - return ((TextContent) result.content().get(0)).text(); + Content first = result.content().getFirst(); + assertInstanceOf(TextContent.class, first, "Content should be TextContent"); + return ((TextContent) first).text(); } protected static void assertNotError(CallToolResult result) { if (Boolean.TRUE.equals(result.isError())) { String errorText = result.content().isEmpty() ? "unknown error" - : ((TextContent) result.content().get(0)).text(); + : ((TextContent) result.content().getFirst()).text(); fail("MCP tool call returned error: " + errorText); } } diff --git a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java index d708082b..3813675f 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java @@ -18,17 +18,24 @@ import static org.junit.jupiter.api.Assertions.*; +import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.Arrays; import java.util.List; +import java.util.stream.Stream; import org.apache.solr.mcp.server.collection.CollectionService; import org.apache.solr.mcp.server.indexing.IndexingService; import org.apache.solr.mcp.server.schema.SchemaService; import org.apache.solr.mcp.server.search.SearchService; +import org.apache.solr.mcp.server.util.PromptNames; import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpComplete; +import org.springaicommunity.mcp.annotation.McpPrompt; +import org.springaicommunity.mcp.annotation.McpResource; import org.springaicommunity.mcp.annotation.McpTool; import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.security.access.prepost.PreAuthorize; /** * Tests for MCP tool registration and annotation validation. Ensures all @@ -180,6 +187,68 @@ void testMcpToolParametersFollowConventions() throws NoSuchMethodException { } } + @Test + void testCollectionCompletionsCoverSchemaResourceAndCollectionTakingPrompts() { + // Each (ref/prompt name, ref/resource uri) pair is a separate completion + // binding in the MCP protocol, so every prompt that takes a `collection` + // argument needs its own @McpComplete handler. The resource-template handler + // alone does NOT cover prompt arguments — Spring AI registers them in + // disjoint maps keyed by reference type. + List completions = Arrays.stream(CollectionService.class.getDeclaredMethods()) + .filter(m -> m.isAnnotationPresent(McpComplete.class)).map(m -> m.getAnnotation(McpComplete.class)) + .toList(); + + // Exactly one resource-template binding (the {collection}/schema URI). + List uriBindings = completions.stream().map(McpComplete::uri).filter(s -> !s.isEmpty()).sorted() + .toList(); + assertEquals(List.of("solr://{collection}/schema"), uriBindings, + "Exactly one resource-template completion expected (the schema URI)"); + + // One prompt binding per prompt that takes a `collection` argument. + // Referenced via PromptNames so a rename or deletion breaks compilation + // here, not silently at runtime. + List promptBindings = completions.stream().map(McpComplete::prompt).filter(s -> !s.isEmpty()).sorted() + .toList(); + assertEquals( + Stream.of(PromptNames.DESIGN_SCHEMA, PromptNames.INDEX_DATA, PromptNames.SEARCH_COLLECTION, + PromptNames.VIEW_SCHEMA).sorted().toList(), + promptBindings, "Every prompt with a `collection` arg needs its own @McpComplete(prompt=...) binding"); + + // `uri` and `prompt` are mutually exclusive per the @McpComplete contract. + for (McpComplete c : completions) { + assertTrue(c.uri().isEmpty() ^ c.prompt().isEmpty(), + "@McpComplete must set exactly one of uri/prompt, not both or neither"); + } + } + + /** + * Invariant: every public MCP entry point — tool, resource, prompt, or + * completion — must carry {@code @PreAuthorize}. Annotating a shared helper is + * not sufficient because Spring's proxy-based method security is bypassed by + * self-invocation, so each MCP-visible method must be gated independently. + * + *

+ * Adding a new {@code @Mcp*} method without {@code @PreAuthorize} fails this + * test, surfacing the omission in CI rather than relying on reviewer memory. + */ + @Test + void everyMcpEndpointIsPreAuthorized() { + List> mcpAnnotations = List.of(McpTool.class, McpResource.class, McpPrompt.class, + McpComplete.class); + + List violations = Stream + .of(CollectionService.class, SchemaService.class, SearchService.class, IndexingService.class) + .flatMap(c -> Arrays.stream(c.getDeclaredMethods())) + .filter(m -> mcpAnnotations.stream().anyMatch(m::isAnnotationPresent)) + .filter(m -> !m.isAnnotationPresent(PreAuthorize.class)) + .map(m -> m.getDeclaringClass().getSimpleName() + "#" + m.getName()).sorted().toList(); + + assertTrue(violations.isEmpty(), + "Every @McpTool / @McpResource / @McpPrompt / @McpComplete method must declare @PreAuthorize. " + + "Self-invocation bypasses the Spring Security proxy, so a shared helper's annotation does not " + + "protect the public entry point. Missing on: " + violations); + } + // Helper method to extract tool names from a service class private void addToolNames(Class serviceClass, List toolNames) { Method[] methods = serviceClass.getDeclaredMethods(); diff --git a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java index 482c290a..d482c175 100644 --- a/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java @@ -22,11 +22,13 @@ import static org.mockito.Mockito.*; import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; import java.io.IOException; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.stream.IntStream; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrServerException; @@ -866,4 +868,129 @@ void createCollection_solrException_propagates() throws Exception { assertThrows(SolrServerException.class, () -> collectionService.createCollection("fail_core", null, null, null)); } + + @Test + void exploreCollectionsPrompt_isReadOnlyAndReferencesKeyTools() { + String body = collectionService.exploreCollectionsPrompt(); + + assertNotNull(body); + assertTrue(body.contains("list-collections"), "Prompt should reference list-collections tool"); + assertTrue(body.contains("get-collection-stats"), "Prompt should reference get-collection-stats tool"); + assertTrue(body.contains("check-health"), "Prompt should reference check-health tool"); + assertFalse(body.contains("create-collection"), + "Explore prompt is read-only; should not direct the LLM to create-collection"); + assertTrue(body.contains("setup-collection"), + "Explore prompt should cross-reference setup-collection for follow-up"); + } + + @Test + void setupCollectionPrompt_includesNameAndInterpolatedDefaults() { + String body = collectionService.setupCollectionPrompt("widgets", "Catalog of widgets"); + + assertNotNull(body); + assertTrue(body.contains("widgets"), "Prompt should embed the chosen collection name"); + assertTrue(body.contains("Catalog of widgets"), "Prompt should embed the purpose when provided"); + assertTrue(body.contains("create-collection"), "Setup prompt should reference create-collection tool"); + assertTrue(body.contains("_default"), "Setup prompt should mention the default configset"); + assertTrue(body.contains("design-schema"), "Setup prompt should cross-reference design-schema for follow-up"); + } + + @Test + void setupCollectionPrompt_omitsPurposeLineWhenBlank() { + String body = collectionService.setupCollectionPrompt("widgets", null); + + assertFalse(body.contains("Purpose:"), "Purpose line should be omitted when no purpose is provided"); + } + + // completeCollection tests + @Test + void completeCollection_WithMatchingPrefix_ReturnsMatches() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("products", "prod-logs", "users")).when(spyService).listCollections(); + + List result = spyService.completeCollection(new CompleteRequest.CompleteArgument("collection", "prod")); + + assertEquals(List.of("prod-logs", "products"), result); + } + + @Test + void completeCollection_WithEmptyPrefix_ReturnsAllSorted() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("zeta", "alpha", "mu")).when(spyService).listCollections(); + + List result = spyService.completeCollection(new CompleteRequest.CompleteArgument("collection", "")); + + assertEquals(List.of("alpha", "mu", "zeta"), result); + } + + @Test + void completeCollection_WithNullValue_ReturnsAllSorted() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("zeta", "alpha")).when(spyService).listCollections(); + + List result = spyService.completeCollection(new CompleteRequest.CompleteArgument("collection", null)); + + assertEquals(List.of("alpha", "zeta"), result); + } + + @Test + void completeCollection_IsCaseInsensitive() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("Products", "PROD_LOGS", "users")).when(spyService).listCollections(); + + List result = spyService.completeCollection(new CompleteRequest.CompleteArgument("collection", "prod")); + + assertEquals(List.of("PROD_LOGS", "Products"), result); + } + + @Test + void completeCollection_WithNoMatches_ReturnsEmpty() throws Exception { + CollectionService spyService = spy(collectionService); + doReturn(Arrays.asList("alpha", "beta")).when(spyService).listCollections(); + + List result = spyService.completeCollection(new CompleteRequest.CompleteArgument("collection", "zzz")); + + assertTrue(result.isEmpty()); + } + + @Test + void completeCollection_WithWrongArgumentName_ReturnsEmpty() throws Exception { + CollectionService spyService = spy(collectionService); + // Should not even attempt to list collections when the argument name does not + // match the template variable. + List result = spyService.completeCollection(new CompleteRequest.CompleteArgument("field", "prod")); + + assertTrue(result.isEmpty()); + verify(spyService, never()).listCollections(); + } + + @Test + void completeCollection_WithNullArgument_ReturnsEmpty() { + List result = collectionService.completeCollection(null); + + assertTrue(result.isEmpty()); + } + + @Test + void completeCollection_CapsResultsAtMax() throws Exception { + CollectionService spyService = spy(collectionService); + List many = IntStream.range(0, CollectionService.MAX_COMPLETION_RESULTS + 25) + .mapToObj(i -> String.format("c%04d", i)).toList(); + doReturn(many).when(spyService).listCollections(); + + List result = spyService.completeCollection(new CompleteRequest.CompleteArgument("collection", "c")); + + assertEquals(CollectionService.MAX_COMPLETION_RESULTS, result.size()); + assertEquals("c0000", result.get(0)); + } + + @Test + void completeCollection_WhenListCollectionsFails_ReturnsEmpty() throws Exception { + when(solrClient.request(any(), any())).thenThrow(new SolrServerException("connection refused")); + + List result = collectionService + .completeCollection(new CompleteRequest.CompleteArgument("collection", "prod")); + + assertTrue(result.isEmpty()); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java index 9b3cd840..5262a8e4 100644 --- a/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/containerization/DockerImageHttpIntegrationTest.java @@ -115,6 +115,13 @@ class DockerImageHttpIntegrationTest { // MCP Server container (the image we're testing) // Note: In HTTP mode, the application exposes a web server on port 8080 + // No OAUTH2_ISSUER_URI is supplied because this smoke test has no IdP + // available. The image must still start: HttpSecurityConfiguration is + // expected to skip OAuth2 wiring when the issuer URL is unset, leaving + // /actuator/health on its permitAll() rule. AOT bakes in the secured + // SecurityFilterChain bean (because @ConditionalOnProperty is evaluated at + // build time with http.security.enabled defaulting to true), so the + // runtime null-issuer guard is what keeps native-http boot-stable. @Container private static final GenericContainer mcpServerContainer = new GenericContainer<>( DockerImageName.parse(DOCKER_IMAGE)).withNetwork(network).withEnv("SOLR_URL", "http://solr:8983/solr/") diff --git a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java index 186b5435..88c749ed 100644 --- a/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/indexing/IndexingServiceTest.java @@ -323,4 +323,42 @@ private List createMockDocuments(int count) { } return docs; } + + @Test + void indexDataPrompt_jsonPath_referencesIndexJsonDocuments() { + String sample = """ + [{"id":"1","title":"Test"}]"""; + + String body = indexingService.indexDataPrompt("library", "json", sample); + + assertTrue(body.contains("library"), "Prompt should mention the target collection name"); + assertTrue(body.contains("index-json-documents"), "JSON path should reference index-json-documents tool"); + assertTrue(body.contains("get-schema"), "Prompt should reference get-schema for verification"); + assertTrue(body.contains("design-schema"), + "Prompt should reference design-schema as fallback when fields are missing"); + assertTrue(body.contains(sample), "Prompt should embed the sample payload"); + } + + @Test + void indexDataPrompt_csvPath_referencesIndexCsvDocuments() { + String body = indexingService.indexDataPrompt("library", "csv", null); + + assertTrue(body.contains("index-csv-documents"), "CSV path should reference index-csv-documents tool"); + assertFalse(body.contains("index-json-documents"), "CSV path should not reference index-json-documents tool"); + } + + @Test + void indexDataPrompt_xmlPath_referencesIndexXmlDocuments() { + String body = indexingService.indexDataPrompt("library", "xml", null); + + assertTrue(body.contains("index-xml-documents"), "XML path should reference index-xml-documents tool"); + } + + @Test + void indexDataPrompt_unknownFormat_throwsIllegalArgumentException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> indexingService.indexDataPrompt("library", "yaml", null)); + assertTrue(ex.getMessage().contains("json/csv/xml"), + "Exception message should list the supported formats: " + ex.getMessage()); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/observability/DistributedTracingTest.java b/src/test/java/org/apache/solr/mcp/server/observability/DistributedTracingTest.java index 71d6e406..04d93425 100644 --- a/src/test/java/org/apache/solr/mcp/server/observability/DistributedTracingTest.java +++ b/src/test/java/org/apache/solr/mcp/server/observability/DistributedTracingTest.java @@ -51,18 +51,19 @@ * requiring external infrastructure. This is the Spring Boot 3 recommended * approach. */ -@SpringBootTest(properties = { - // Enable HTTP mode for observability - "spring.profiles.active=http", - // Tracing test does not exercise the OAuth2 filter chain; opt out of - // secure-by-default to avoid requiring a live JWKS endpoint at startup. - "http.security.enabled=false", - // Disable OTLP export in tests - we're using SimpleTracer instead - "management.otlp.tracing.endpoint=", "management.opentelemetry.logging.export.otlp.enabled=false", - // Ensure 100% sampling for tests - "management.tracing.sampling.probability=1.0", - // Enable @Observed annotation support - "management.observations.annotations.enabled=true"}) +@SpringBootTest( + properties = { + // Enable HTTP mode for observability + "spring.profiles.active=http", + // Tracing test does not exercise the OAuth2 filter chain; opt out of + // secure-by-default to avoid requiring a live JWKS endpoint at startup. + "http.security.enabled=false", + // Disable OTLP export in tests - we're using SimpleTracer instead + "management.otlp.tracing.endpoint=", "management.opentelemetry.logging.export.otlp.enabled=false", + // Ensure 100% sampling for tests + "management.tracing.sampling.probability=1.0", + // Enable @Observed annotation support + "management.observations.annotations.enabled=true"}) @Import({TestcontainersConfiguration.class, OpenTelemetryTestConfiguration.class}) @Tag("integration") @Testcontainers(disabledWithoutDocker = true) diff --git a/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java index c247d485..2c83aae4 100644 --- a/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/observability/OtlpExportIntegrationTest.java @@ -74,9 +74,11 @@ * SimpleTracer and passes all tests successfully. */ @Disabled("Jetty HTTP client ClassNotFoundException with LgtmStackContainer - see class javadoc") -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { - // Ensure 100% sampling for tests - "management.tracing.sampling.probability=1.0"}) +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + // Ensure 100% sampling for tests + "management.tracing.sampling.probability=1.0"}) @Import(TestcontainersConfiguration.class) @Tag("integration") @Testcontainers(disabledWithoutDocker = true) diff --git a/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java index 27a206e0..0ca900e6 100644 --- a/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java @@ -316,4 +316,43 @@ void addFieldTypes_analyzerWithOnlyClassKey_preservesClassInWireFormat() throws assertTrue(body.contains("solr.TextField"), "Wire body must preserve the field-type-level 'class' key: " + body); } + + @Test + void designSchemaPrompt_includesKeyWorkflowSteps() { + String body = schemaService.designSchemaPrompt("products", "A catalog of products with title and price", null); + + assertNotNull(body); + assertTrue(body.contains("products"), "Prompt should mention the target collection name"); + assertTrue(body.contains("get-schema"), "Prompt should reference get-schema tool"); + assertTrue(body.contains("add-fields"), "Prompt should reference add-fields tool"); + assertTrue(body.contains("add-field-types"), "Prompt should reference add-field-types tool"); + assertTrue(body.contains("text_general"), "Prompt should mention common Solr field types"); + assertTrue(body.contains("transactional"), "Prompt should warn about Schema API atomicity"); + } + + @Test + void designSchemaPrompt_embedsSampleDocumentWhenProvided() { + String sample = """ + {"id":"sku-1","title":"Widget","price":9.99}"""; + + String body = schemaService.designSchemaPrompt("products", "Catalog", sample); + + assertTrue(body.contains(sample), "Prompt should include the sample document body"); + } + + @Test + void viewSchemaPrompt_isReadOnlyAndReferencesGetSchema() { + String body = schemaService.viewSchemaPrompt("products"); + + assertNotNull(body); + assertTrue(body.contains("products"), "Prompt should mention the target collection name"); + assertTrue(body.contains("get-schema"), "Prompt should reference get-schema tool"); + assertTrue(body.contains("uniqueKey"), "Prompt should explain uniqueKey"); + assertTrue(body.contains("dynamic"), "Prompt should explain dynamic fields"); + assertTrue(body.contains("copyField") || body.contains("copy field") || body.contains("copyFields"), + "Prompt should explain copy fields"); + assertFalse(body.contains("add-fields"), "View prompt is read-only; should not direct the LLM to add-fields"); + assertTrue(body.contains("design-schema"), + "View prompt should cross-reference design-schema for follow-up modification"); + } } diff --git a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java index e12d9d12..b0c6cd6a 100644 --- a/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/search/SearchServiceTest.java @@ -307,4 +307,18 @@ private List createMockFacetFields() { authorFacet.add("Joshua Bloch", 1); return List.of(genreFacet, authorFacet); } + + @Test + void searchCollectionPrompt_includesKeyWorkflowSteps() { + SearchService localService = new SearchService(mock(SolrClient.class)); + String body = localService.searchCollectionPrompt("shows", "What sci-fi shows are on Netflix?"); + + assertNotNull(body); + assertTrue(body.contains("shows"), "Prompt should mention the target collection name"); + assertTrue(body.contains("What sci-fi shows are on Netflix?"), "Prompt should embed the user question"); + assertTrue(body.contains("get-schema"), "Prompt should reference get-schema tool"); + assertTrue(body.contains("search"), "Prompt should reference search tool"); + assertTrue(body.contains("filterQueries"), "Prompt should explain filterQueries"); + assertTrue(body.contains("numFound"), "Prompt should mention numFound interpretation"); + } }