From 4cbb0820024f91dbc52de321f61e83b6eb0d92d9 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 01:20:47 -0400 Subject: [PATCH 01/27] docs: add design spec and implementation plan for schema modification Spec and plan for adding add-fields and add-field-types MCP tools per issue #30. See docs/superpowers/specs/ and docs/superpowers/plans/. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../plans/2026-05-17-schema-modification.md | 1011 +++++++++++++++++ .../2026-05-17-schema-modification-design.md | 404 +++++++ 2 files changed, 1415 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-17-schema-modification.md create mode 100644 docs/superpowers/specs/2026-05-17-schema-modification-design.md diff --git a/docs/superpowers/plans/2026-05-17-schema-modification.md b/docs/superpowers/plans/2026-05-17-schema-modification.md new file mode 100644 index 00000000..15a6f741 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-schema-modification.md @@ -0,0 +1,1011 @@ +# Schema Modification Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two MCP tools — `add-fields` and `add-field-types` — to the Solr MCP server so AI assistants can extend a collection's schema through the MCP protocol, partially closing [apache/solr-mcp#30](https://github.com/apache/solr-mcp/issues/30). + +**Architecture:** Two new methods on the existing `SchemaService` use SolrJ's `SchemaRequest.MultiUpdate` to batch additive schema changes. Inputs are `List>` matching Solr's Schema API JSON shape (no transformation layer). `addFieldTypes` includes a small manual conversion from flat input maps to SolrJ's typed `FieldTypeDefinition` (since SolrJ splits `name`/`class` into an attributes map and pulls analyzers into typed sub-objects). + +**Tech Stack:** Java 25, Spring Boot 3.5, Spring AI MCP 1.1.4, SolrJ 10.0, JUnit 5.12, Mockito, Testcontainers, Gradle. + +**Reference spec:** [`docs/superpowers/specs/2026-05-17-schema-modification-design.md`](../specs/2026-05-17-schema-modification-design.md) + +--- + +## File Structure + +**Create:** +- `src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java` — MCP tool response record + +**Modify:** +- `src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java` — add 2 `@McpTool` methods + 2 private helpers +- `src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java` — register `SchemaUpdateResult` for reflection +- `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java` — extend with unit tests for new methods +- `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java` — extend with integration tests for new methods +- `src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java` — append ordered tests 16–18 exercising new MCP tools +- `README.md` — document the two new MCP tools +- `CLAUDE.md` — update SchemaService description + +**Carries over from `docs-restructure` branch:** +- `docs/superpowers/specs/2026-05-17-schema-modification-design.md` — design spec +- `docs/superpowers/plans/2026-05-17-schema-modification.md` — this plan + +--- + +## Task 1: Branch setup and initial spec/plan commit + +**Files:** +- Sync: local `main` with `upstream/main` +- Create branch: `schema-modification` off updated `main` +- Carry over (unstaged): `docs/superpowers/specs/2026-05-17-schema-modification-design.md`, `docs/superpowers/plans/2026-05-17-schema-modification.md` + +- [ ] **Step 1: Confirm with user before touching origin** + +The next step does `git push origin main` after a `git reset --hard upstream/main` on local `main`. That's a remote-affecting operation. Ask the user to confirm before proceeding. + +- [ ] **Step 2: Verify spec and plan files are present and unstaged on current branch** + +```bash +git status -- docs/superpowers/specs/2026-05-17-schema-modification-design.md docs/superpowers/plans/2026-05-17-schema-modification.md +``` + +Expected: both files appear as `??` (untracked). They must NOT be committed on `docs-restructure`. If they are committed there, stop and resolve before continuing. + +- [ ] **Step 3: Sync local main with upstream main** + +```bash +git fetch upstream +git checkout main +git reset --hard upstream/main +git push origin main +``` + +Expected: `origin/main` is now identical to `upstream/main`. Working tree still contains the untracked spec + plan files (they survive branch switches because they're untracked). + +- [ ] **Step 4: Create the feature branch** + +```bash +git checkout -b schema-modification +``` + +Expected: on branch `schema-modification`, untracked files still present. + +- [ ] **Step 5: Commit spec and plan** + +```bash +git add docs/superpowers/specs/2026-05-17-schema-modification-design.md \ + docs/superpowers/plans/2026-05-17-schema-modification.md +git commit -s -m "$(cat <<'EOF' +docs: add design spec and implementation plan for schema modification + +Spec and plan for adding add-fields and add-field-types MCP tools per +issue #30. See docs/superpowers/specs/ and docs/superpowers/plans/. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +Expected: commit succeeds. `git log -1 --stat` shows both files added. + +--- + +## Task 2: Create `SchemaUpdateResult` record + +**Files:** +- Create: `src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java` + +No tests for the record itself (Java records are trivial); it'll be exercised by every test in subsequent tasks. + +- [ ] **Step 1: Create the record file** + +```java +/* + * 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.metadata; + +import java.util.Date; +import java.util.List; + +/** + * Result of an additive schema update (add-fields or add-field-types). + * + *

{@code success} is always {@code true} on return — failures throw and never produce a + * result. {@code addedNames} echoes the {@code name} field from each input definition in + * input order, useful for confirming what was sent. + */ +public record SchemaUpdateResult(String collection, boolean success, List addedNames, Date timestamp) { +} +``` + +- [ ] **Step 2: Verify compilation** + +```bash +./gradlew compileJava +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** + +```bash +git add src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java +git commit -s -m "$(cat <<'EOF' +feat(metadata): add SchemaUpdateResult record for schema modification tools + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 3: Implement `addFields` (TDD) + +**Files:** +- Modify: `src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java` +- Modify: `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java` + +Existing imports in `SchemaServiceTest` already include `SchemaRequest` and the mock infrastructure. Use the same `MockitoExtension` setup. + +- [ ] **Step 1: Add the failing tests to `SchemaServiceTest`** + +Append after the existing tests (before the closing brace). Add the necessary imports at the top: `org.apache.solr.client.solrj.SolrRequest`, `org.apache.solr.client.solrj.request.schema.SchemaRequest.AddField`, `org.apache.solr.client.solrj.request.schema.SchemaRequest.MultiUpdate`, `org.apache.solr.client.solrj.request.schema.SchemaRequest.Update`, `org.apache.solr.common.util.NamedList`, `org.mockito.ArgumentCaptor`, `java.util.List`, `java.util.Map`. + +```java +@Test +void addFields_blankCollection_throws() { + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFields(null, List.of(Map.of("name", "x", "type", "string")))); + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFields("", List.of(Map.of("name", "x", "type", "string")))); + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFields(" ", List.of(Map.of("name", "x", "type", "string")))); +} + +@Test +void addFields_emptyList_throws() { + assertThrows(IllegalArgumentException.class, () -> schemaService.addFields("col", null)); + assertThrows(IllegalArgumentException.class, () -> schemaService.addFields("col", List.of())); +} + +@Test +void addFields_happyPath_buildsMultiUpdateAndEchoesNames() throws Exception { + List> fields = List.of( + Map.of("name", "title", "type", "text_general", "stored", true, "indexed", true), + Map.of("name", "platform", "type", "string", "stored", true, "indexed", true, "docValues", true)); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new NamedList<>()); + + SchemaUpdateResult result = schemaService.addFields("col", fields); + + assertTrue(result.success()); + assertEquals(List.of("title", "platform"), result.addedNames()); + assertEquals("col", result.collection()); + assertNotNull(result.timestamp()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SolrRequest.class); + verify(solrClient).request(captor.capture(), eq("col")); + assertInstanceOf(MultiUpdate.class, captor.getValue()); +} + +@Test +void addFields_solrThrows_propagates() throws Exception { + when(solrClient.request(any(SolrRequest.class), eq("col"))) + .thenThrow(new SolrServerException("simulated")); + + assertThrows(SolrServerException.class, + () -> schemaService.addFields("col", List.of(Map.of("name", "x", "type", "string")))); +} +``` + +Also add `import static org.mockito.Mockito.verify;` to the existing static imports. + +- [ ] **Step 2: Run tests, expect failure** + +```bash +./gradlew test --tests SchemaServiceTest -i +``` + +Expected: 4 new tests FAIL with compilation errors (no `addFields` method on `SchemaService`). + +- [ ] **Step 3: Implement `addFields` in `SchemaService.java`** + +Add the imports (top of file): `java.util.ArrayList`, `java.util.Date`, `java.util.List`, `java.util.Map`, `org.apache.solr.client.solrj.request.schema.SchemaRequest.Update`. + +Add the method at the end of the class (before the closing brace). Also add a private validation helper. + +```java +@PreAuthorize("isAuthenticated()") +@McpTool(name = "add-fields", 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. " + + "Commands run in input order; if one fails mid-batch, prior commands remain applied " + + "(use get-schema to inspect on failure).") +public SchemaUpdateResult addFields( + @McpToolParam(description = "Solr collection name") String collection, + @McpToolParam(description = "List of field definitions (Solr add-field JSON shape)") + List> fields) + throws SolrServerException, IOException { + requireCollection(collection); + requireNonEmpty(fields, "fields"); + + List names = new ArrayList<>(fields.size()); + List updates = new ArrayList<>(fields.size()); + for (Map field : fields) { + names.add(String.valueOf(field.get("name"))); + updates.add(new SchemaRequest.AddField(field)); + } + + new SchemaRequest.MultiUpdate(updates).process(solrClient, collection); + return new SchemaUpdateResult(collection, true, names, new Date()); +} + +private static void requireCollection(String collection) { + if (collection == null || collection.isBlank()) { + throw new IllegalArgumentException("Collection name must not be blank"); + } +} + +private static void requireNonEmpty(List list, String name) { + if (list == null || list.isEmpty()) { + throw new IllegalArgumentException(name + " must not be empty"); + } +} +``` + +Add to existing imports: `org.springaicommunity.mcp.annotation.McpToolParam`, `java.io.IOException`, `org.apache.solr.client.solrj.SolrServerException`, `org.apache.solr.client.solrj.request.schema.SchemaRequest`. + +- [ ] **Step 4: Run tests, expect pass** + +```bash +./gradlew test --tests SchemaServiceTest -i +``` + +Expected: all SchemaServiceTest tests PASS, including the 4 new ones. + +- [ ] **Step 5: Apply Spotless and commit** + +```bash +./gradlew spotlessApply +git add src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java \ + src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java +git commit -s -m "$(cat <<'EOF' +feat(metadata): add add-fields MCP tool for additive schema modification + +Closes part of #30. Adds one or more fields atomically per call via +SolrJ's SchemaRequest.MultiUpdate. Input is List> +matching the Solr Schema API add-field JSON shape; validation is +limited to collection name and non-empty list (Solr returns clear +errors for malformed field defs). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 4: Implement `addFieldTypes` + `toFieldTypeDefinition` (TDD) + +**Files:** +- Modify: `src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java` +- Modify: `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java` + +- [ ] **Step 1: Add the failing tests to `SchemaServiceTest`** + +Add the import: `org.apache.solr.client.solrj.request.schema.AnalyzerDefinition`, `org.apache.solr.client.solrj.request.schema.FieldTypeDefinition`, `org.apache.solr.client.solrj.request.schema.SchemaRequest.AddFieldType`. + +Append after the addFields tests: + +```java +@Test +void addFieldTypes_blankCollection_throws() { + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFieldTypes(null, + List.of(Map.of("name", "x", "class", "solr.StrField")))); + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFieldTypes("", + List.of(Map.of("name", "x", "class", "solr.StrField")))); +} + +@Test +void addFieldTypes_emptyList_throws() { + assertThrows(IllegalArgumentException.class, () -> schemaService.addFieldTypes("col", null)); + assertThrows(IllegalArgumentException.class, () -> schemaService.addFieldTypes("col", List.of())); +} + +@Test +void addFieldTypes_happyPathWithAnalyzer_buildsCorrectFieldTypeDefinition() throws Exception { + // Use a real ObjectMapper so convertValue actually works; rebuild service with it. + ObjectMapper realMapper = new ObjectMapper(); + SchemaService service = new SchemaService(solrClient, realMapper); + + List> types = List.of(Map.of( + "name", "text_lowercase", + "class", "solr.TextField", + "analyzer", Map.of( + "tokenizer", Map.of("class", "solr.KeywordTokenizerFactory"), + "filters", List.of(Map.of("class", "solr.LowerCaseFilterFactory"))))); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new NamedList<>()); + + SchemaUpdateResult result = service.addFieldTypes("col", types); + + assertTrue(result.success()); + assertEquals(List.of("text_lowercase"), result.addedNames()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SolrRequest.class); + verify(solrClient).request(captor.capture(), eq("col")); + assertInstanceOf(MultiUpdate.class, captor.getValue()); +} + +@Test +void addFieldTypes_separateAnalyzers_buildsCorrectFieldTypeDefinition() throws Exception { + ObjectMapper realMapper = new ObjectMapper(); + SchemaService service = new SchemaService(solrClient, realMapper); + + List> types = List.of(Map.of( + "name", "text_autocomplete", + "class", "solr.TextField", + "indexAnalyzer", Map.of( + "tokenizer", Map.of("class", "solr.KeywordTokenizerFactory"), + "filters", List.of( + Map.of("class", "solr.LowerCaseFilterFactory"), + Map.of("class", "solr.EdgeNGramFilterFactory", "minGramSize", 2, "maxGramSize", 20))), + "queryAnalyzer", Map.of( + "tokenizer", Map.of("class", "solr.KeywordTokenizerFactory"), + "filters", List.of(Map.of("class", "solr.LowerCaseFilterFactory"))))); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new NamedList<>()); + + SchemaUpdateResult result = service.addFieldTypes("col", types); + + assertTrue(result.success()); + assertEquals(List.of("text_autocomplete"), result.addedNames()); +} + +@Test +void addFieldTypes_denseVectorField_noAnalyzer() throws Exception { + ObjectMapper realMapper = new ObjectMapper(); + SchemaService service = new SchemaService(solrClient, realMapper); + + List> types = List.of(Map.of( + "name", "openai_embedding", + "class", "solr.DenseVectorField", + "vectorDimension", 1536, + "similarityFunction", "cosine", + "knnAlgorithm", "hnsw")); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new NamedList<>()); + + SchemaUpdateResult result = service.addFieldTypes("col", types); + + assertTrue(result.success()); + assertEquals(List.of("openai_embedding"), result.addedNames()); +} + +@Test +void addFieldTypes_solrThrows_propagates() throws Exception { + ObjectMapper realMapper = new ObjectMapper(); + SchemaService service = new SchemaService(solrClient, realMapper); + + when(solrClient.request(any(SolrRequest.class), eq("col"))) + .thenThrow(new SolrServerException("simulated")); + + assertThrows(SolrServerException.class, + () -> service.addFieldTypes("col", + List.of(Map.of("name", "x", "class", "solr.StrField")))); +} +``` + +Note: tests use a real `ObjectMapper` rather than the `@Mock` one because `toFieldTypeDefinition` calls `convertValue(...)` which must actually work. The class-level `@Mock ObjectMapper` stays — only the field-type tests construct a real one. + +- [ ] **Step 2: Run tests, expect failure** + +```bash +./gradlew test --tests SchemaServiceTest -i +``` + +Expected: 5 new tests FAIL with compilation errors (no `addFieldTypes` method). + +- [ ] **Step 3: Implement `addFieldTypes` + `toFieldTypeDefinition` in `SchemaService.java`** + +Add imports: `java.util.LinkedHashMap`, `org.apache.solr.client.solrj.request.schema.AnalyzerDefinition`, `org.apache.solr.client.solrj.request.schema.FieldTypeDefinition`. + +Append after `addFields`: + +```java +@PreAuthorize("isAuthenticated()") +@McpTool(name = "add-field-types", 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. " + + "Commands run in input order; partial application possible on failure.") +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) + throws SolrServerException, IOException { + requireCollection(collection); + requireNonEmpty(fieldTypes, "fieldTypes"); + + List names = new ArrayList<>(fieldTypes.size()); + List updates = new ArrayList<>(fieldTypes.size()); + for (Map fieldType : fieldTypes) { + names.add(String.valueOf(fieldType.get("name"))); + updates.add(new SchemaRequest.AddFieldType(toFieldTypeDefinition(fieldType))); + } + + new SchemaRequest.MultiUpdate(updates).process(solrClient, collection); + return new SchemaUpdateResult(collection, true, names, new Date()); +} + +/** + * Builds a {@link FieldTypeDefinition} from a flat input map matching the Solr Schema API + * add-field-type JSON shape. SolrJ's {@code FieldTypeDefinition} stores name/class and + * other scalar attributes inside an attributes {@link Map}, with analyzers pulled into + * typed sub-objects — so we can't deserialize the flat input directly via Jackson. + */ +private FieldTypeDefinition toFieldTypeDefinition(Map input) { + FieldTypeDefinition def = new FieldTypeDefinition(); + Map attributes = new LinkedHashMap<>(input); + Object analyzer = attributes.remove("analyzer"); + Object indexAnalyzer = attributes.remove("indexAnalyzer"); + Object queryAnalyzer = attributes.remove("queryAnalyzer"); + def.setAttributes(attributes); + if (analyzer != null) { + def.setAnalyzer(toAnalyzerDefinition(analyzer)); + } + if (indexAnalyzer != null) { + def.setIndexAnalyzer(toAnalyzerDefinition(indexAnalyzer)); + } + if (queryAnalyzer != null) { + def.setQueryAnalyzer(toAnalyzerDefinition(queryAnalyzer)); + } + return def; +} + +private AnalyzerDefinition toAnalyzerDefinition(Object raw) { + return objectMapper.convertValue(raw, AnalyzerDefinition.class); +} +``` + +- [ ] **Step 4: Run tests, expect pass** + +```bash +./gradlew test --tests SchemaServiceTest -i +``` + +Expected: all 9+ new tests PASS plus the pre-existing tests still pass. + +- [ ] **Step 5: Apply Spotless and commit** + +```bash +./gradlew spotlessApply +git add src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java \ + src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java +git commit -s -m "$(cat <<'EOF' +feat(metadata): add add-field-types MCP tool with FieldTypeDefinition helper + +Supports single analyzer, separate index/query analyzers, and non-analyzer +field types like DenseVectorField. Manual conversion from flat input map +to SolrJ FieldTypeDefinition because name/class go into attributes map. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 5: Register `SchemaUpdateResult` for native image reflection + +**Files:** +- Modify: `src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java` + +- [ ] **Step 1: Add `SchemaUpdateResult` to the `MCP_RESPONSE_RECORDS` list** + +Open `SolrNativeHints.java`. Find the `MCP_RESPONSE_RECORDS` list (around line 63). Append the new entry. The list becomes: + +```java +private static final List MCP_RESPONSE_RECORDS = List.of( + "org.apache.solr.mcp.server.collection.CollectionCreationResult", + "org.apache.solr.mcp.server.collection.SolrHealthStatus", + "org.apache.solr.mcp.server.collection.SolrMetrics", "org.apache.solr.mcp.server.collection.IndexStats", + "org.apache.solr.mcp.server.collection.FieldStats", "org.apache.solr.mcp.server.collection.QueryStats", + "org.apache.solr.mcp.server.collection.CacheStats", "org.apache.solr.mcp.server.collection.CacheInfo", + "org.apache.solr.mcp.server.collection.HandlerStats", "org.apache.solr.mcp.server.collection.HandlerInfo", + "org.apache.solr.mcp.server.search.SearchResponse", + "org.apache.solr.mcp.server.metadata.SchemaUpdateResult"); +``` + +- [ ] **Step 2: Verify compilation** + +```bash +./gradlew compileJava +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 3: Apply Spotless and commit** + +```bash +./gradlew spotlessApply +git add src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java +git commit -s -m "$(cat <<'EOF' +feat(config): register SchemaUpdateResult for GraalVM native image reflection + +Same pattern as the other @McpTool response records — invisible to AOT +because MCP dispatches via Object. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 6: Extend `SchemaServiceIntegrationTest` with real-Solr integration tests + +**Files:** +- Modify: `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java` + +Reuse the existing `TEST_COLLECTION = "schema_test_collection"` setup. Use unique field/type names per test method to avoid collisions across test ordering. + +- [ ] **Step 1: Add the integration tests** + +Append after the existing tests. Add imports: `java.util.List`, `java.util.Map`, `org.apache.solr.client.solrj.SolrServerException`, `org.apache.solr.client.solrj.response.QueryResponse`, `org.apache.solr.client.solrj.request.SolrQuery`, `org.apache.solr.common.SolrInputDocument`. + +```java +@Test +void addFields_endToEnd_persistsToSchema() throws Exception { + List> fields = List.of( + Map.of("name", "addf_title", "type", "text_general", "stored", true, "indexed", true), + Map.of("name", "addf_platform", "type", "string", "stored", true, "indexed", true, + "docValues", true), + Map.of("name", "addf_year", "type", "pint", "stored", true, "indexed", true, "docValues", true)); + + SchemaUpdateResult result = schemaService.addFields(TEST_COLLECTION, fields); + + assertTrue(result.success()); + assertEquals(List.of("addf_title", "addf_platform", "addf_year"), result.addedNames()); + + SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); + Map title = schema.getFields().stream() + .filter(f -> "addf_title".equals(f.get("name"))).findFirst().orElseThrow(); + Map platform = schema.getFields().stream() + .filter(f -> "addf_platform".equals(f.get("name"))).findFirst().orElseThrow(); + Map year = schema.getFields().stream() + .filter(f -> "addf_year".equals(f.get("name"))).findFirst().orElseThrow(); + + assertEquals("text_general", title.get("type")); + assertEquals("string", platform.get("type")); + assertEquals(Boolean.TRUE, platform.get("docValues")); + assertEquals("pint", year.get("type")); +} + +@Test +void addFieldTypes_customAnalyzer_appliesAtIndexAndQuery() throws Exception { + schemaService.addFieldTypes(TEST_COLLECTION, List.of(Map.of( + "name", "aft_text_ci_keyword", + "class", "solr.TextField", + "analyzer", Map.of( + "tokenizer", Map.of("class", "solr.KeywordTokenizerFactory"), + "filters", List.of(Map.of("class", "solr.LowerCaseFilterFactory")))))); + + schemaService.addFields(TEST_COLLECTION, List.of(Map.of( + "name", "aft_ci_platform", + "type", "aft_text_ci_keyword", + "stored", true, "indexed", true))); + + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "aft-doc-1"); + doc.addField("aft_ci_platform", "NetFlix"); + solrClient.add(TEST_COLLECTION, doc); + solrClient.commit(TEST_COLLECTION); + + QueryResponse lowercase = solrClient.query(TEST_COLLECTION, new SolrQuery("aft_ci_platform:netflix")); + assertEquals(1L, lowercase.getResults().getNumFound()); + + QueryResponse uppercase = solrClient.query(TEST_COLLECTION, new SolrQuery("aft_ci_platform:NETFLIX")); + assertEquals(1L, uppercase.getResults().getNumFound()); + + QueryResponse partial = solrClient.query(TEST_COLLECTION, new SolrQuery("aft_ci_platform:net")); + assertEquals(0L, partial.getResults().getNumFound()); +} + +@Test +void addFieldTypes_denseVectorField_schemaRoundTrip() throws Exception { + schemaService.addFieldTypes(TEST_COLLECTION, List.of(Map.of( + "name", "aft_test_vector", + "class", "solr.DenseVectorField", + "vectorDimension", 4, + "similarityFunction", "cosine"))); + + SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); + Map vt = schema.getFieldTypes().stream() + .filter(t -> "aft_test_vector".equals(t.getAttributes().get("name"))) + .findFirst().orElseThrow().getAttributes(); + + assertEquals("solr.DenseVectorField", vt.get("class")); + assertEquals(4, ((Number) vt.get("vectorDimension")).intValue()); + assertEquals("cosine", vt.get("similarityFunction")); +} + +@Test +void addFields_duplicateField_throws() throws Exception { + List> field = List.of( + Map.of("name", "addf_dup_field", "type", "string", "stored", true, "indexed", true)); + schemaService.addFields(TEST_COLLECTION, field); + + // Second call with same name — Solr returns an error in the response body. + // Whether SolrJ throws or returns silently is part of what this test verifies. + Exception ex = assertThrows(Exception.class, + () -> schemaService.addFields(TEST_COLLECTION, field)); + assertTrue(ex instanceof SolrServerException || ex instanceof RuntimeException, + "Expected SolrServerException or RuntimeException, got " + ex.getClass()); +} + +@Test +void addFields_unknownType_throws() { + List> field = List.of( + Map.of("name", "addf_broken", "type", "totally_not_a_real_type")); + assertThrows(Exception.class, () -> schemaService.addFields(TEST_COLLECTION, field)); +} +``` + +If the `addFields_duplicateField_throws` test FAILS (i.e., the second call returns normally because SolrJ doesn't throw on response-body errors), then `SchemaService.addFields` and `addFieldTypes` need to be updated to inspect the response and throw explicitly. This is the verification the spec called out. Add the inspection code: + +```java +// Inside addFields/addFieldTypes after .process(...): +SchemaResponse.UpdateResponse response = + new SchemaRequest.MultiUpdate(updates).process(solrClient, collection); +@SuppressWarnings("unchecked") +List errors = (List) response.getResponse().get("errors"); +if (errors != null && !errors.isEmpty()) { + throw new SolrServerException("Schema update returned errors: " + errors); +} +``` + +Re-run the test and confirm it passes. + +- [ ] **Step 2: Run integration tests** + +```bash +./gradlew test --tests SchemaServiceIntegrationTest -i +``` + +Expected: all integration tests PASS (existing + 5 new). If Docker isn't running, the test class is disabled automatically. + +- [ ] **Step 3: Apply Spotless and commit** + +```bash +./gradlew spotlessApply +git add src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java \ + src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java +git commit -s -m "$(cat <<'EOF' +test(metadata): integration tests for add-fields and add-field-types + +End-to-end against real Solr via Testcontainers. Verifies schema +round-trip, custom analyzer behavior, vector field type registration, +and error propagation on duplicate field / unknown type. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +Note: the commit may include `SchemaService.java` if the response-body inspection was added in Step 1. + +--- + +## Task 7: Extend `McpClientIntegrationTestBase` with MCP protocol tests + +**Files:** +- Modify: `src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java` + +Reuse the existing `COLLECTION = "mcp-client-test"`. The new fields are added after the existing CSV indexing tests (order 14, 15). New tests are at orders 16, 17, 18. + +- [ ] **Step 1: Add the ordered tests** + +Append after the `searchFindsAllDocumentsAfterCsvIndexing()` test (order 15), before the `protected static String extractText(...)` helpers. + +```java +@Test +@Order(16) +void addFieldsToTestCollection() throws Exception { + List> fields = List.of( + Map.of("name", "platform", "type", "string", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "release_year", "type", "pint", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "genres", "type", "strings", "stored", true, "indexed", true, "docValues", true)); + + CallToolResult result = mcpClient.callTool(new CallToolRequest("add-fields", + Map.of("collection", COLLECTION, "fields", fields))); + + assertNotNull(result); + assertNotError(result); + String text = extractText(result); + assertTrue(text.contains("platform"), "Result should mention added 'platform': " + text); + assertTrue(text.contains("release_year"), "Result should mention added 'release_year': " + text); + assertTrue(text.contains("genres"), "Result should mention added 'genres': " + text); +} + +@Test +@Order(17) +void indexDocumentWithNewFields() { + String json = """ + [ + {"id": "show-1", "title": "Breaking Bad", "author": "Vince Gilligan", + "category": "show", "platform": "Netflix", + "release_year": 2008, "genres": ["drama", "crime"]} + ] + """; + + CallToolResult result = mcpClient.callTool(new CallToolRequest("index-json-documents", + Map.of("collection", COLLECTION, "json", json))); + + assertNotNull(result); + assertNotError(result); +} + +@Test +@Order(18) +void searchWithNewFieldFilters() throws Exception { + CallToolResult byPlatform = mcpClient.callTool(new CallToolRequest("search", + Map.of("collection", COLLECTION, "query", "*:*", + "filterQueries", List.of("platform:Netflix")))); + Map r1 = OBJECT_MAPPER.readValue(extractText(byPlatform), new TypeReference<>() { + }); + assertEquals(1, getNumFound(r1), "Should find exactly 1 doc with platform=Netflix"); + + CallToolResult byGenre = mcpClient.callTool(new CallToolRequest("search", + Map.of("collection", COLLECTION, "query", "*:*", + "filterQueries", List.of("genres:crime")))); + Map r2 = OBJECT_MAPPER.readValue(extractText(byGenre), new TypeReference<>() { + }); + assertEquals(1, getNumFound(r2), "Multi-valued 'genres' should match on 'crime'"); +} +``` + +Also update the `listToolsReturnsExpectedTools` test (order 2) to assert the new tool names appear: + +```java +assertTrue(toolNames.contains("add-fields"), "Should have add-fields tool"); +assertTrue(toolNames.contains("add-field-types"), "Should have add-field-types tool"); +``` + +- [ ] **Step 2: Run both HTTP and stdio variants** + +```bash +./gradlew test --tests McpClientIntegrationTest --tests McpClientStdioIntegrationTest -i +``` + +Expected: both subclasses PASS the full 18-test sequence. If the HTTP variant fails on `add-fields` with a 401/403, the test infrastructure for the HTTP transport isn't supplying auth correctly. In that case, inspect `McpClientIntegrationTest` to see how it authenticates other `@PreAuthorize` tool calls (`create-collection`, `list-collections`) — the same mechanism applies. + +- [ ] **Step 3: Apply Spotless and commit** + +```bash +./gradlew spotlessApply +git add src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java +git commit -s -m "$(cat <<'EOF' +test: extend MCP client integration tests for schema modification tools + +Adds ordered tests 16-18 exercising the add-fields → index → search +workflow through the MCP protocol against both HTTP and stdio transports. +Also asserts add-fields and add-field-types appear in listTools output. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 8: Update README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Locate the MCP tools table or list** + +```bash +grep -n -A 2 -B 2 "get-schema\|list-collections\|create-collection" README.md | head -40 +``` + +Identify the section where existing tools are listed (likely a table or bullet list under an "MCP Tools" heading). + +- [ ] **Step 2: Add the two new tools** + +Add entries for `add-fields` and `add-field-types` in the same style as adjacent tools. Use these descriptions: + +- `add-fields` — Add one or more fields to a Solr collection schema (additive only; existing fields cannot be modified). +- `add-field-types` — Add one or more field types to a Solr collection schema (supports custom analyzers, DenseVectorField for semantic search, etc.). + +If the README has a worked example section, add a small example showing the shows-collection workflow (create-collection → add-fields → index-json-documents → search). Keep it under ~15 lines. + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -s -m "$(cat <<'EOF' +docs: document add-fields and add-field-types MCP tools in README + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 9: Update CLAUDE.md + +**Files:** +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Update the SchemaService entry** + +Find the `- **SchemaService**` line under "MCP Tools" → "Architecture". Change: + +``` +- **SchemaService** (`metadata/`) - Schema introspection +``` + +to: + +``` +- **SchemaService** (`metadata/`) - Schema introspection and additive modification (add-fields, add-field-types) +``` + +- [ ] **Step 2: Commit** + +```bash +git add CLAUDE.md +git commit -s -m "$(cat <<'EOF' +docs: update CLAUDE.md SchemaService entry for new schema-modification tools + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 10: Full build verification + +- [ ] **Step 1: Spotless check** + +```bash +./gradlew spotlessCheck +``` + +Expected: BUILD SUCCESSFUL. If it fails, run `./gradlew spotlessApply`, inspect changes with `git diff`, commit them with `style: spotless` if non-trivial, otherwise amend the most recent commit. + +- [ ] **Step 2: Full build (JVM path)** + +```bash +./gradlew build +``` + +Expected: BUILD SUCCESSFUL. All tests pass including the new unit + integration + MCP client tests. If any pre-existing test regresses, stop and investigate before continuing. + +- [ ] **Step 3: Native test (optional but recommended)** + +```bash +./gradlew nativeTest -Pnative +``` + +Expected: BUILD SUCCESSFUL. Mockito-based unit tests are skipped via `@DisabledInNativeImage`; integration tests run. If the native test fails with a "missing reflection metadata" error for a SolrJ type involved in schema modification (e.g., `FieldTypeDefinition`, `AnalyzerDefinition`), add reflection registrations to `SolrNativeHints.Registrar.registerHints()`: + +```java +hints.reflection().registerType( + org.apache.solr.client.solrj.request.schema.FieldTypeDefinition.class, categories); +hints.reflection().registerType( + org.apache.solr.client.solrj.request.schema.AnalyzerDefinition.class, categories); +``` + +Then commit: + +```bash +git add src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java +git commit -s -m "$(cat <<'EOF' +fix(native): register SolrJ schema-modification types for reflection + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 4: Docker integration test (optional)** + +```bash +./gradlew dockerIntegrationTest +``` + +Expected: BUILD SUCCESSFUL. Exercises the new MCP tool tests via the Jib JVM image, end-to-end through the MCP protocol over both stdio and HTTP transports. + +Only run this if Docker is available. If it fails on the new tests but passes on master, investigate before moving on. + +--- + +## Task 11: Push branch and open PR + +- [ ] **Step 1: Confirm with user before pushing** + +Pushing the branch to `origin` and opening a PR are remote-visible actions. Stop and ask the user to confirm both before proceeding. + +- [ ] **Step 2: Push the branch** + +```bash +git push -u origin schema-modification +``` + +- [ ] **Step 3: Open PR against `apache/solr-mcp:main`** + +```bash +gh pr create --repo apache/solr-mcp --base main --head adityamparikh:schema-modification \ + --title "feat: add schema modification MCP tools (add-fields, add-field-types)" \ + --body "$(cat <<'EOF' +## Summary +- Adds `add-fields` and `add-field-types` MCP tools to extend a collection's schema additively from the MCP layer +- Partially closes #30 — `replace-*`, `delete-*`, `add-copy-field`, `add-dynamic-field`, and `add-codec-factory` deferred (see spec for rationale) +- Both tools take `List>` matching Solr's Schema API JSON shape; batched via `SchemaRequest.MultiUpdate` +- Both tools are `@PreAuthorize("isAuthenticated()")` — HTTP enforces auth, stdio bypasses (same pattern as existing tools) + +## Design and plan +- Spec: `docs/superpowers/specs/2026-05-17-schema-modification-design.md` +- Plan: `docs/superpowers/plans/2026-05-17-schema-modification.md` + +## Test plan +- [x] Unit tests (Mockito, `@DisabledInNativeImage`) — validation, happy path, error propagation +- [x] Integration tests (Testcontainers, real Solr) — schema round-trip, custom analyzer behavior, DenseVectorField, duplicate/unknown-type errors +- [x] MCP protocol tests (`McpClientIntegrationTestBase`) — end-to-end add-fields → index → search via MCP tool calls over both HTTP and stdio transports +- [x] Full `./gradlew build` passes with no regressions +- [ ] `./gradlew nativeTest -Pnative` (verify in CI) +- [ ] `./gradlew dockerIntegrationTest` (verify in CI) + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +Expected: PR is created and the URL is printed. Share the URL with the user. + +--- + +## Notes for the executor + +- **Conventional commits.** Every commit uses a Conventional Commits prefix (`feat`, `test`, `docs`, `fix`, `chore`, `style`) and the scope where applicable (`feat(metadata):`, `test(metadata):`, `docs:`). +- **Signoffs.** Every commit uses `-s` (or includes `Signed-off-by:` manually). User's global CLAUDE.md requires this. +- **Spotless.** Run `./gradlew spotlessApply` before each commit. The pre-commit hook or CI will reject unformatted code otherwise. +- **No `--no-verify` or `--no-gpg-sign`.** Hard rule per the system prompt. +- **Confirm before remote-affecting operations.** Step 3 of Task 1 (`git push origin main`), Steps 2–3 of Task 11 (push and PR creation). +- **`SchemaResponse.UpdateResponse` shape verification.** Task 6 Step 1 includes the verification — if the duplicate-field test passes without manual error inspection, SolrJ throws automatically and the code is fine as written. If it fails, add the response-body inspection shown in that step and re-run. diff --git a/docs/superpowers/specs/2026-05-17-schema-modification-design.md b/docs/superpowers/specs/2026-05-17-schema-modification-design.md new file mode 100644 index 00000000..51b59ad0 --- /dev/null +++ b/docs/superpowers/specs/2026-05-17-schema-modification-design.md @@ -0,0 +1,404 @@ +# Schema modification MCP tools — design + +**Status:** draft, pending user approval +**Issue:** [apache/solr-mcp#30](https://github.com/apache/solr-mcp/issues/30) (partial — see Scope) +**Branch:** `schema-modification` + +## Problem + +`SchemaService` exposes one MCP tool today: `get-schema`, which is read-only. AI assistants +can inspect a Solr collection's schema but cannot extend it. The only way to add fields or +field types is to drop out of the AI workflow and POST JSON to Solr's Schema API by hand: + +```bash +curl -X POST -H 'Content-Type:application/json' \ + http://localhost:8983/solr/shows/schema \ + -d '{"add-field": [{"name":"title","type":"text_general", ...}, ...]}' +``` + +This breaks the end-to-end "set up a collection, define its shape, index data" workflow +through an AI assistant. + +The motivating use case: ask an AI to create a `shows` collection and define a schema with +fields like `title text_general`, `platform string` (for exact-match faceting), `release_year +pint`, `genres strings` (multi-valued), and so on — properties that Solr's schemaless mode +won't infer correctly. + +### Why not just rely on schemaless mode / dynamic fields? + +The `_default` configset has schemaless mode enabled and ships with dynamic-field patterns +(`*_s`, `*_i`, `*_txt`, ...). For casual exploration these cover a lot. They fail for the +motivating use case because: + +- Schemaless guesses `text_general` for strings, but several shows fields need `string` for + exact-match faceting (`platform`, `country`, `language`, `rating`). +- Schemaless never sets `docValues=true`. The shows spec wants `docValues=true` on 11 of 16 + fields for sorting/faceting/function-query efficiency. +- Schemaless infers multi-valued from the first doc's value shape — fragile under data drift. +- Schemaless cannot define `DenseVectorField` (vector search needs explicit `vectorDimension`, + `similarityFunction`, `knnAlgorithm` — the secondary issue-#30 motivation). + +Explicit `add-fields` / `add-field-types` is therefore necessary for any non-casual workflow. + +## Scope + +In scope: + +- `add-fields` MCP tool — add one or more fields to an existing collection's schema. +- `add-field-types` MCP tool — add one or more field types (including custom analyzer chains + and `DenseVectorField` for vector search) to an existing collection's schema. + +Out of scope (issue #30 still partially open after merge): + +- `replace-field` / `replace-field-type` — silently breaks existing indexed data without + reindex. AI-driven workflows are the wrong place to expose that footgun without a + guardrail design. +- `delete-field` / `delete-field-type` — same risk; orphan data; cascading effects on + field types used by multiple fields. +- `add-copy-field` / `add-dynamic-field` — useful but not motivating; defer to follow-up. +- `add-codec-factory` (issue #30 third bullet) — uses Config API, not Schema API; different + code path; different risk profile. + +## Decisions (from brainstorming) + +| Decision | Choice | Rationale | +|---|---|---| +| Operations | Add-only; no replace/delete | Replace/delete silently corrupt indexed data without explicit reindex. Add-only is safe; orphan fields/types are harmless. | +| Batching | Batch per call (list of definitions) | Matches Solr Schema API wire format; one round-trip. SolrJ's `SchemaRequest.MultiUpdate` is built for this. | +| Parameter shape | `List>` | Maps 1:1 to Solr's JSON. Records can't cleanly express analyzer nesting + arbitrary per-factory params. Matches SolrJ's `AddField(Map)` constructor — zero transformation. | +| One tool vs two | Two separate tools | LLM tool-use guidance favors single-purpose tools. Two single-list-parameter tools eliminate cross-wire risk vs a combined tool with two optional lists. | +| Failure mode | Throw on any Solr error | Matches `createCollection`. Partial-failure case is rare in practice; when it does happen the LLM can call `get-schema` to inspect. Avoids inventing a `failures` shape for a rare case. | + +## Architecture + +Add the two tools as methods on the existing `SchemaService` +(`src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java`). Same package, +same constructor dependencies (`SolrClient`, `ObjectMapper`), same annotations as +`getSchema`. No new service class. + +``` +SchemaService +├── getSchema(String collection) — existing +├── addFields(collection, fields) — NEW +└── addFieldTypes(collection, fieldTypes) — NEW +``` + +### Method signatures + +Tool descriptions are deliberately long and include inline recipes (case-insensitive exact +match, dense vector, autocomplete). LLMs use these recipes as the diagnostic-to-fix bridge: +the user describes a symptom ("my filter doesn't match Netflix"), the LLM matches the +symptom to a recipe in the description, and the recipe gives the LLM the exact analyzer +chain to construct. Generic shape-only descriptions are not sufficient — a strong model +might still produce a working chain from training data, but inline recipes improve +reliability and reduce variance across model capabilities. + +```java +@PreAuthorize("isAuthenticated()") +@McpTool( + name = "add-fields", + 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. " + + "Commands run in input order; if one fails mid-batch, prior commands remain applied " + + "(use get-schema to inspect on failure)." +) +public SchemaUpdateResult addFields( + @McpToolParam(description = "Solr collection name") String collection, + @McpToolParam(description = "List of field definitions (Solr add-field JSON shape)") + List> fields +) throws SolrServerException, IOException; + +@PreAuthorize("isAuthenticated()") +@McpTool( + name = "add-field-types", + 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. " + + "Commands run in input order; partial application possible on failure." +) +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 +) throws SolrServerException, IOException; +``` + +### Result type + +New record `SchemaUpdateResult` in a new file +`src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java`. Shape matches +`CollectionCreationResult` (project convention): + +```java +public record SchemaUpdateResult( + String collection, + boolean success, + List addedNames, + Date timestamp +) {} +``` + +`success` is always `true` on return (failures throw). `addedNames` echoes the `name` from +each input definition in input order. No `failures` field — see Failure mode below. + +### Implementation skeleton + +```java +public SchemaUpdateResult addFields(String collection, List> fields) + throws SolrServerException, IOException { + if (collection == null || collection.isBlank()) { + throw new IllegalArgumentException("Collection name must not be blank"); + } + if (fields == null || fields.isEmpty()) { + throw new IllegalArgumentException("fields must not be empty"); + } + List names = new ArrayList<>(fields.size()); + List updates = new ArrayList<>(fields.size()); + for (Map field : fields) { + names.add(String.valueOf(field.get("name"))); + updates.add(new SchemaRequest.AddField(field)); + } + new SchemaRequest.MultiUpdate(updates).process(solrClient, collection); + return new SchemaUpdateResult(collection, true, names, new Date()); +} +``` + +`addFieldTypes` is the same shape but each map needs conversion to `FieldTypeDefinition` +(see below). + +### `FieldTypeDefinition` conversion helper + +`FieldTypeDefinition` (SolrJ) doesn't accept `name`/`class` as top-level setters — those go +into the attributes map. So a flat input map doesn't deserialize directly via Jackson. A +small private helper builds it manually: + +```java +private FieldTypeDefinition toFieldTypeDefinition(Map input) { + FieldTypeDefinition def = new FieldTypeDefinition(); + Map attributes = new LinkedHashMap<>(input); + Object analyzer = attributes.remove("analyzer"); + Object indexAnalyzer = attributes.remove("indexAnalyzer"); + Object queryAnalyzer = attributes.remove("queryAnalyzer"); + def.setAttributes(attributes); + if (analyzer != null) def.setAnalyzer(toAnalyzerDefinition(analyzer)); + if (indexAnalyzer != null) def.setIndexAnalyzer(toAnalyzerDefinition(indexAnalyzer)); + if (queryAnalyzer != null) def.setQueryAnalyzer(toAnalyzerDefinition(queryAnalyzer)); + return def; +} + +private AnalyzerDefinition toAnalyzerDefinition(Object raw) { + return objectMapper.convertValue(raw, AnalyzerDefinition.class); +} +``` + +`AnalyzerDefinition` (with its nested `charFilters`, `tokenizer`, `filters` lists of +`{class, params...}` maps) is structurally amenable to Jackson `convertValue`. Verify by +integration test. + +### Validation + +Minimal — match `createCollection` style: + +- `collection` not null, not blank → `IllegalArgumentException` +- `fields` / `fieldTypes` not null, not empty → `IllegalArgumentException` + +No per-map key validation. Solr returns clear errors for missing/invalid keys; pre-validating +duplicates that work and adds maintenance. + +### Failure mode + +- Bad input (blank collection, empty list) → `IllegalArgumentException` (Spring AI MCP + converts to tool error) +- Solr transport failure → `SolrServerException` / `IOException` propagate +- Solr-side command failure → `SolrServerException` propagates from + `MultiUpdate.process()` (verify exact behavior in integration test — Solr returns errors + in the response body; SolrJ may or may not throw automatically. If it doesn't, inspect + `response.getResponse().get("errors")` and throw explicitly. **This API shape needs + integration-test verification before relying on it.**) + +`MultiUpdate` is **not atomic** — commands process sequentially server-side and a failure +mid-batch leaves prior commands applied. The result type doesn't model this because the +common case is whole-batch success or whole-batch failure on command #1 (the typical +errors — already-exists, unknown-type-reference — fail fast at the first invalid command). +Rare mid-batch failures surface as exceptions; caller can call `get-schema` to see what +landed. + +### Native image hints + +Add to `src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java`: + +- `SchemaUpdateResult` — invisible to AOT (MCP dispatches via `Object`), same pattern as + `CollectionCreationResult` + +For SolrJ types (`SchemaRequest.AddField`, `AddFieldType`, `MultiUpdate`, +`FieldTypeDefinition`, `AnalyzerDefinition`): verify by running +`./gradlew nativeTest -Pnative` after the implementation pass. Add reflection registrations +only if tests fail. + +Resource hints: none new. + +## Testing + +### `SchemaServiceTest` (unit, Mockito, `@DisabledInNativeImage`) + +New file at `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java` +(or extend if exists). Cases: + +- `addFields_blankCollection_throws()` — null and blank collection +- `addFields_emptyList_throws()` — null and empty list +- `addFields_happyPath_buildsMultiUpdate()` — capture `SolrRequest` argument to + `solrClient.request(...)`, assert it is a `MultiUpdate` carrying the expected `AddField`s + in input order +- `addFields_solrThrows_propagates()` — mock SolrClient to throw `SolrServerException`, + assert it surfaces unchanged +- Same four cases for `addFieldTypes`, plus: + - `addFieldTypes_withAnalyzer_buildsFieldTypeDefinition()` — input has nested analyzer, + assert the `FieldTypeDefinition` passed to `AddFieldType` has its analyzer set with the + expected tokenizer/filters + +### `SchemaServiceIntegrationTest` (Testcontainers, real Solr) + +New file `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java`. +Pattern follows existing `*IntegrationTest` classes (real `SolrContainer`, real `SolrClient`). + +- `addFields_endToEnd_persistsToSchema()` — create collection via `CollectionService`, call + `addFields` with 3 fields covering `string`/`text_general`/`pint`, then call `getSchema` + and assert all 3 appear with the right types and properties (including `docValues=true` + where set) +- `addFieldTypes_endToEnd_persistsToSchema()` — add a custom field type with an analyzer + (e.g., `text_lowercase` with `KeywordTokenizerFactory` + `LowerCaseFilterFactory`), then + add a field using that type, then index a doc and assert the lowercase analyzer was + applied (query for the field with mixed-case input matches) +- `addFields_duplicateField_throws()` — add a field, then call again with the same name; + assert exception (Solr returns "Field 'X' already exists") +- `addFields_unknownType_throws()` — try to add a field with `type: "nonexistent_type"`; + assert exception + +The duplicate-field and unknown-type tests also serve to **verify the response error +shape** assumption noted under Failure mode. + +### `McpClientIntegrationTestBase` + +Append ordered tests to the existing `mcp-client-test` collection (reuse — by test 16 the +prior assertions are done, and adding fields doesn't disturb them): + +```java +@Test @Order(16) +void addFieldsToTestCollection() { + // call add-fields with a subset of the shows-style schema: + // {name: "platform", type: "string", stored: true, indexed: true, docValues: true} + // {name: "release_year", type: "pint", stored: true, indexed: true, docValues: true} + // {name: "genres", type: "strings", stored: true, indexed: true, docValues: true} + // assert result has success=true and addedNames in expected order +} + +@Test @Order(17) +void indexDocumentWithNewFields() { + // index-json-documents with one doc using the new fields: + // {id: "show-1", title: "Breaking Bad", platform: "Netflix", + // release_year: 2008, genres: ["drama","crime"]} + // assert no error +} + +@Test @Order(18) +void searchWithNewFieldFilter() { + // search with filterQueries=["platform:Netflix"] + // assert numFound=1 and the returned doc has title="Breaking Bad" +} +``` + +Skip `add-field-types` in `McpClientIntegrationTestBase` — covered in +`SchemaServiceIntegrationTest`. Keeps the MCP-protocol-level test focused on the user's +motivating workflow. + +### Docker / native test coverage + +No changes to `dockerIntegrationTest` or `nativeTest` configuration. The new methods are +exercised by the existing test runs: + +- JVM unit + integration: `./gradlew build` +- Native: `./gradlew nativeTest -Pnative` (unit Mockito tests stay `@DisabledInNativeImage`) +- Docker MCP protocol: `./gradlew dockerIntegrationTest` (runs `McpClientIntegrationTestBase` + subclasses against the Jib image, including the new ordered tests) + +## Docs + +- **`README.md`** — append rows for `add-fields` and `add-field-types` to the existing MCP + tools list, one line each, matching the style of `get-schema`. +- **`CLAUDE.md`** — under "MCP Tools" → SchemaService entry, change from "Schema + introspection" to "Schema introspection and additive modification". One sentence. +- **No new doc files.** + +## Git workflow + +```bash +# Sync local main with upstream main (will confirm with user before push) +git checkout main +git fetch upstream +git reset --hard upstream/main +git push origin main + +# Branch off updated main +git checkout -b schema-modification +``` + +**Spec file handling.** This spec is written on the `docs-restructure` branch but the +implementation work happens on `schema-modification` (off main). To avoid cherry-picking: + +1. Do not commit the spec on `docs-restructure`. +2. After `git checkout -b schema-modification`, the unstaged spec file in + `docs/superpowers/specs/` carries over to the new branch automatically. +3. First commit on `schema-modification` includes the spec. + +Untracked `.DS_Store` and the rest of `docs/superpowers/` stay alone. + +## Commit conventions + +Per project + user CLAUDE.md: + +- Conventional Commits: `feat(metadata): add add-fields and add-field-types MCP tools` +- `Signed-off-by:` in every commit (user's global instruction; `git commit -s`) +- `Co-Authored-By: Claude Opus 4.7 (1M context) ` + +## Open questions resolved during brainstorming + +- **Why not include modification ops (replace/delete)?** Replace/delete silently break + indexed data without reindex. AI-driven workflows are exactly the wrong place to expose + that footgun. Defer until we design a guardrail (e.g. mandatory + `acknowledgeReindexRequired: true`). +- **Why not include codec factory?** Different API (Config API vs Schema API), different + risk profile (wrong codec choice can break an index), and the motivating use case + doesn't need it. +- **Why two tools instead of one combined?** LLM tool-use guidance favors single-purpose + tools. Combined tool's two-optional-list shape risks LLMs cross-wiring field defs and + type defs. Orphan-field-type cost of separation is harmless. +- **Why `List>` instead of strongly-typed records?** Solr field-type + shape includes analyzers/tokenizers/filters with class-specific param bags; records + collapse to `Map` at the leaves anyway. Map shape matches SolrJ's + `AddField(Map)` constructor — zero transformation. +- **Why not skip this and rely on schemaless mode?** Schemaless gets `string` vs + `text_general` wrong for the motivating use case, never sets `docValues`, infers + multi-valued fragilely from doc 1, and cannot define vector fields at all. +- **Why throw on failure instead of returning a partial-result type?** Most failures fail + fast at command #1 (already-exists, unknown-type). Mid-batch partial failure is rare; + modeling it with a `List` adds a type for an edge case. Caller can + call `get-schema` after a failure to see what landed. +- **Why no per-key map validation?** Solr returns clear errors for missing/invalid keys. + Pre-validating duplicates Solr's work and adds maintenance burden. Matches + `createCollection` style (only validates collection name). From 2049a1ca4bfca268a8b0a51342be0f432a69aaf6 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 01:21:45 -0400 Subject: [PATCH 02/27] feat(metadata): add SchemaUpdateResult record for schema modification tools Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../server/metadata/SchemaUpdateResult.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java b/src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java new file mode 100644 index 00000000..ff840d01 --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java @@ -0,0 +1,31 @@ +/* + * 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.metadata; + +import java.util.Date; +import java.util.List; + +/** + * Result of an additive schema update (add-fields or add-field-types). + * + *

+ * {@code success} is always {@code true} on return — failures throw and never + * produce a result. {@code addedNames} echoes the {@code name} field from each + * input definition in input order, useful for confirming what was sent. + */ +public record SchemaUpdateResult(String collection, boolean success, List addedNames, Date timestamp) { +} From c6629d287946e15e9887f4960d8275a9edcbcaf5 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 01:25:01 -0400 Subject: [PATCH 03/27] chore(metadata): align SchemaUpdateResult Jackson annotations with project DTO convention @JsonIgnoreProperties, @JsonInclude(NON_NULL), and @JsonFormat on the timestamp field match the pattern used by every record in Dtos.java. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../solr/mcp/server/metadata/SchemaUpdateResult.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java b/src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java index ff840d01..3b4a5474 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java +++ b/src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java @@ -16,6 +16,9 @@ */ package org.apache.solr.mcp.server.metadata; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; import java.util.Date; import java.util.List; @@ -27,5 +30,8 @@ * produce a result. {@code addedNames} echoes the {@code name} field from each * input definition in input order, useful for confirming what was sent. */ -public record SchemaUpdateResult(String collection, boolean success, List addedNames, Date timestamp) { +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SchemaUpdateResult(String collection, boolean success, List addedNames, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date timestamp) { } From 5eadc89397834bd519a92a2b6894359342f4da69 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 01:29:44 -0400 Subject: [PATCH 04/27] refactor: rename metadata package to schema Package previously named "metadata" only contained schema-related types (SchemaService, SchemaUpdateResult, and their tests). Renaming to "schema" makes the package name accurate to its contents. Moves preserve git history via git mv. Imports updated in Main, MainTest, and McpToolRegistrationTest. Spec and plan paths updated. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../plans/2026-05-17-schema-modification.md | 52 +++++++++---------- .../2026-05-17-schema-modification-design.md | 10 ++-- .../java/org/apache/solr/mcp/server/Main.java | 2 +- .../{metadata => schema}/SchemaService.java | 2 +- .../SchemaUpdateResult.java | 2 +- .../org/apache/solr/mcp/server/MainTest.java | 2 +- .../mcp/server/McpToolRegistrationTest.java | 2 +- .../SchemaServiceIntegrationTest.java | 2 +- .../SchemaServiceTest.java | 2 +- 9 files changed, 38 insertions(+), 38 deletions(-) rename src/main/java/org/apache/solr/mcp/server/{metadata => schema}/SchemaService.java (99%) rename src/main/java/org/apache/solr/mcp/server/{metadata => schema}/SchemaUpdateResult.java (97%) rename src/test/java/org/apache/solr/mcp/server/{metadata => schema}/SchemaServiceIntegrationTest.java (99%) rename src/test/java/org/apache/solr/mcp/server/{metadata => schema}/SchemaServiceTest.java (99%) diff --git a/docs/superpowers/plans/2026-05-17-schema-modification.md b/docs/superpowers/plans/2026-05-17-schema-modification.md index 15a6f741..6b12b24d 100644 --- a/docs/superpowers/plans/2026-05-17-schema-modification.md +++ b/docs/superpowers/plans/2026-05-17-schema-modification.md @@ -15,13 +15,13 @@ ## File Structure **Create:** -- `src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java` — MCP tool response record +- `src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java` — MCP tool response record **Modify:** -- `src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java` — add 2 `@McpTool` methods + 2 private helpers +- `src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java` — add 2 `@McpTool` methods + 2 private helpers - `src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java` — register `SchemaUpdateResult` for reflection -- `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java` — extend with unit tests for new methods -- `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java` — extend with integration tests for new methods +- `src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java` — extend with unit tests for new methods +- `src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java` — extend with integration tests for new methods - `src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java` — append ordered tests 16–18 exercising new MCP tools - `README.md` — document the two new MCP tools - `CLAUDE.md` — update SchemaService description @@ -93,7 +93,7 @@ Expected: commit succeeds. `git log -1 --stat` shows both files added. ## Task 2: Create `SchemaUpdateResult` record **Files:** -- Create: `src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java` +- Create: `src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java` No tests for the record itself (Java records are trivial); it'll be exercised by every test in subsequent tasks. @@ -116,7 +116,7 @@ No tests for the record itself (Java records are trivial); it'll be exercised by * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.schema; import java.util.Date; import java.util.List; @@ -143,9 +143,9 @@ Expected: BUILD SUCCESSFUL. - [ ] **Step 3: Commit** ```bash -git add src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java +git add src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java git commit -s -m "$(cat <<'EOF' -feat(metadata): add SchemaUpdateResult record for schema modification tools +feat(schema): add SchemaUpdateResult record for schema modification tools Co-Authored-By: Claude Opus 4.7 (1M context) EOF @@ -157,8 +157,8 @@ EOF ## Task 3: Implement `addFields` (TDD) **Files:** -- Modify: `src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java` -- Modify: `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java` +- Modify: `src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java` +- Modify: `src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java` Existing imports in `SchemaServiceTest` already include `SchemaRequest` and the mock infrastructure. Use the same `MockitoExtension` setup. @@ -287,10 +287,10 @@ Expected: all SchemaServiceTest tests PASS, including the 4 new ones. ```bash ./gradlew spotlessApply -git add src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java \ - src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java +git add src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java \ + src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java git commit -s -m "$(cat <<'EOF' -feat(metadata): add add-fields MCP tool for additive schema modification +feat(schema): add add-fields MCP tool for additive schema modification Closes part of #30. Adds one or more fields atomically per call via SolrJ's SchemaRequest.MultiUpdate. Input is List> @@ -308,8 +308,8 @@ EOF ## Task 4: Implement `addFieldTypes` + `toFieldTypeDefinition` (TDD) **Files:** -- Modify: `src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java` -- Modify: `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java` +- Modify: `src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java` +- Modify: `src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java` - [ ] **Step 1: Add the failing tests to `SchemaServiceTest`** @@ -511,10 +511,10 @@ Expected: all 9+ new tests PASS plus the pre-existing tests still pass. ```bash ./gradlew spotlessApply -git add src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java \ - src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java +git add src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java \ + src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java git commit -s -m "$(cat <<'EOF' -feat(metadata): add add-field-types MCP tool with FieldTypeDefinition helper +feat(schema): add add-field-types MCP tool with FieldTypeDefinition helper Supports single analyzer, separate index/query analyzers, and non-analyzer field types like DenseVectorField. Manual conversion from flat input map @@ -545,7 +545,7 @@ private static final List MCP_RESPONSE_RECORDS = List.of( "org.apache.solr.mcp.server.collection.CacheStats", "org.apache.solr.mcp.server.collection.CacheInfo", "org.apache.solr.mcp.server.collection.HandlerStats", "org.apache.solr.mcp.server.collection.HandlerInfo", "org.apache.solr.mcp.server.search.SearchResponse", - "org.apache.solr.mcp.server.metadata.SchemaUpdateResult"); + "org.apache.solr.mcp.server.schema.SchemaUpdateResult"); ``` - [ ] **Step 2: Verify compilation** @@ -577,7 +577,7 @@ EOF ## Task 6: Extend `SchemaServiceIntegrationTest` with real-Solr integration tests **Files:** -- Modify: `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java` +- Modify: `src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java` Reuse the existing `TEST_COLLECTION = "schema_test_collection"` setup. Use unique field/type names per test method to avoid collisions across test ordering. @@ -710,10 +710,10 @@ Expected: all integration tests PASS (existing + 5 new). If Docker isn't running ```bash ./gradlew spotlessApply -git add src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java \ - src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java +git add src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java \ + src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java git commit -s -m "$(cat <<'EOF' -test(metadata): integration tests for add-fields and add-field-types +test(schema): integration tests for add-fields and add-field-types End-to-end against real Solr via Testcontainers. Verifies schema round-trip, custom analyzer behavior, vector field type registration, @@ -876,13 +876,13 @@ EOF Find the `- **SchemaService**` line under "MCP Tools" → "Architecture". Change: ``` -- **SchemaService** (`metadata/`) - Schema introspection +- **SchemaService** (`schema/`) - Schema introspection ``` to: ``` -- **SchemaService** (`metadata/`) - Schema introspection and additive modification (add-fields, add-field-types) +- **SchemaService** (`schema/`) - Schema introspection and additive modification (add-fields, add-field-types) ``` - [ ] **Step 2: Commit** @@ -1003,7 +1003,7 @@ Expected: PR is created and the URL is printed. Share the URL with the user. ## Notes for the executor -- **Conventional commits.** Every commit uses a Conventional Commits prefix (`feat`, `test`, `docs`, `fix`, `chore`, `style`) and the scope where applicable (`feat(metadata):`, `test(metadata):`, `docs:`). +- **Conventional commits.** Every commit uses a Conventional Commits prefix (`feat`, `test`, `docs`, `fix`, `chore`, `style`) and the scope where applicable (`feat(schema):`, `test(schema):`, `docs:`). - **Signoffs.** Every commit uses `-s` (or includes `Signed-off-by:` manually). User's global CLAUDE.md requires this. - **Spotless.** Run `./gradlew spotlessApply` before each commit. The pre-commit hook or CI will reject unformatted code otherwise. - **No `--no-verify` or `--no-gpg-sign`.** Hard rule per the system prompt. diff --git a/docs/superpowers/specs/2026-05-17-schema-modification-design.md b/docs/superpowers/specs/2026-05-17-schema-modification-design.md index 51b59ad0..ff872f8a 100644 --- a/docs/superpowers/specs/2026-05-17-schema-modification-design.md +++ b/docs/superpowers/specs/2026-05-17-schema-modification-design.md @@ -72,7 +72,7 @@ Out of scope (issue #30 still partially open after merge): ## Architecture Add the two tools as methods on the existing `SchemaService` -(`src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java`). Same package, +(`src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java`). Same package, same constructor dependencies (`SolrClient`, `ObjectMapper`), same annotations as `getSchema`. No new service class. @@ -142,7 +142,7 @@ public SchemaUpdateResult addFieldTypes( ### Result type New record `SchemaUpdateResult` in a new file -`src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java`. Shape matches +`src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java`. Shape matches `CollectionCreationResult` (project convention): ```java @@ -257,7 +257,7 @@ Resource hints: none new. ### `SchemaServiceTest` (unit, Mockito, `@DisabledInNativeImage`) -New file at `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java` +New file at `src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java` (or extend if exists). Cases: - `addFields_blankCollection_throws()` — null and blank collection @@ -274,7 +274,7 @@ New file at `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest ### `SchemaServiceIntegrationTest` (Testcontainers, real Solr) -New file `src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java`. +New file `src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java`. Pattern follows existing `*IntegrationTest` classes (real `SolrContainer`, real `SolrClient`). - `addFields_endToEnd_persistsToSchema()` — create collection via `CollectionService`, call @@ -372,7 +372,7 @@ Untracked `.DS_Store` and the rest of `docs/superpowers/` stay alone. Per project + user CLAUDE.md: -- Conventional Commits: `feat(metadata): add add-fields and add-field-types MCP tools` +- Conventional Commits: `feat(schema): add add-fields and add-field-types MCP tools` - `Signed-off-by:` in every commit (user's global instruction; `git commit -s`) - `Co-Authored-By: Claude Opus 4.7 (1M context) ` diff --git a/src/main/java/org/apache/solr/mcp/server/Main.java b/src/main/java/org/apache/solr/mcp/server/Main.java index bf84a234..f6b7fb9a 100644 --- a/src/main/java/org/apache/solr/mcp/server/Main.java +++ b/src/main/java/org/apache/solr/mcp/server/Main.java @@ -18,7 +18,7 @@ import org.apache.solr.mcp.server.collection.CollectionService; import org.apache.solr.mcp.server.indexing.IndexingService; -import org.apache.solr.mcp.server.metadata.SchemaService; +import org.apache.solr.mcp.server.schema.SchemaService; import org.apache.solr.mcp.server.search.SearchService; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java b/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java similarity index 99% rename from src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java rename to src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java index 19c5577d..1d11c472 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaService.java +++ b/src/main/java/org/apache/solr/mcp/server/schema/SchemaService.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.schema; import static org.apache.solr.mcp.server.util.JsonUtils.toJson; diff --git a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java b/src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java similarity index 97% rename from src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java rename to src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java index 3b4a5474..5173c036 100644 --- a/src/main/java/org/apache/solr/mcp/server/metadata/SchemaUpdateResult.java +++ b/src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.schema; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; diff --git a/src/test/java/org/apache/solr/mcp/server/MainTest.java b/src/test/java/org/apache/solr/mcp/server/MainTest.java index 92d921b0..3b6d5ec3 100644 --- a/src/test/java/org/apache/solr/mcp/server/MainTest.java +++ b/src/test/java/org/apache/solr/mcp/server/MainTest.java @@ -18,7 +18,7 @@ import org.apache.solr.mcp.server.collection.CollectionService; import org.apache.solr.mcp.server.indexing.IndexingService; -import org.apache.solr.mcp.server.metadata.SchemaService; +import org.apache.solr.mcp.server.schema.SchemaService; import org.apache.solr.mcp.server.search.SearchService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledInNativeImage; 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 af79754b..d708082b 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java @@ -24,7 +24,7 @@ import java.util.List; import org.apache.solr.mcp.server.collection.CollectionService; import org.apache.solr.mcp.server.indexing.IndexingService; -import org.apache.solr.mcp.server.metadata.SchemaService; +import org.apache.solr.mcp.server.schema.SchemaService; import org.apache.solr.mcp.server.search.SearchService; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpTool; diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java similarity index 99% rename from src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java rename to src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java index d02689cc..439aa762 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.schema; import static org.junit.jupiter.api.Assertions.*; diff --git a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java similarity index 99% rename from src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java rename to src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java index 00e9b750..86005171 100644 --- a/src/test/java/org/apache/solr/mcp/server/metadata/SchemaServiceTest.java +++ b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java @@ -14,7 +14,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.solr.mcp.server.metadata; +package org.apache.solr.mcp.server.schema; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; From 592ede09c3d7fee5138a635f94cf94a266dac948 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 01:34:31 -0400 Subject: [PATCH 05/27] feat(schema): add add-fields MCP tool for additive schema modification Closes part of #30. Adds one or more fields per call via SolrJ's SchemaRequest.MultiUpdate. Input is List> matching the Solr Schema API add-field JSON shape; validation is limited to collection name and non-empty list (Solr returns clear errors for malformed field defs). Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../solr/mcp/server/schema/SchemaService.java | 47 +++++++++++++++++ .../mcp/server/schema/SchemaServiceTest.java | 51 +++++++++++++++++++ 2 files changed, 98 insertions(+) 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 1d11c472..a775c34d 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 @@ -20,11 +20,18 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.micrometer.observation.annotation.Observed; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.schema.SchemaRequest; import org.apache.solr.client.solrj.response.schema.SchemaRepresentation; 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; import org.springframework.stereotype.Service; @@ -256,4 +263,44 @@ public SchemaRepresentation getSchema(String collection) throws Exception { SchemaRequest schemaRequest = new SchemaRequest(); return schemaRequest.process(solrClient, collection).getSchemaRepresentation(); } + + @PreAuthorize("isAuthenticated()") + @McpTool(name = "add-fields", 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. " + + "Commands run in input order; if one fails mid-batch, prior commands remain applied " + + "(use get-schema to inspect on failure).") + public SchemaUpdateResult addFields(@McpToolParam(description = "Solr collection name") String collection, + @McpToolParam(description = "List of field definitions (Solr add-field JSON shape)") List> fields) + throws SolrServerException, IOException { + requireCollection(collection); + requireNonEmpty(fields, "fields"); + + List names = new ArrayList<>(fields.size()); + List updates = new ArrayList<>(fields.size()); + for (Map field : fields) { + names.add(String.valueOf(field.get("name"))); + updates.add(new SchemaRequest.AddField(field)); + } + + new SchemaRequest.MultiUpdate(updates).process(solrClient, collection); + return new SchemaUpdateResult(collection, true, names, new Date()); + } + + private static void requireCollection(String collection) { + if (collection == null || collection.isBlank()) { + throw new IllegalArgumentException("Collection name must not be blank"); + } + } + + private static void requireNonEmpty(List list, String name) { + if (list == null || list.isEmpty()) { + throw new IllegalArgumentException(name + " must not be empty"); + } + } } 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 86005171..26ea0c57 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 @@ -19,19 +19,26 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; +import java.util.List; +import java.util.Map; import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrRequest; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.schema.SchemaRequest; +import org.apache.solr.client.solrj.request.schema.SchemaRequest.MultiUpdate; import org.apache.solr.client.solrj.response.schema.SchemaRepresentation; import org.apache.solr.client.solrj.response.schema.SchemaResponse; +import org.apache.solr.common.util.NamedList; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.DisabledInNativeImage; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -149,4 +156,48 @@ void testConstructor_WithNullClient() { new SchemaService(null, objectMapper); }); } + + @Test + void addFields_blankCollection_throws() { + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFields(null, List.of(Map.of("name", "x", "type", "string")))); + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFields("", List.of(Map.of("name", "x", "type", "string")))); + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFields(" ", List.of(Map.of("name", "x", "type", "string")))); + } + + @Test + void addFields_emptyList_throws() { + assertThrows(IllegalArgumentException.class, () -> schemaService.addFields("col", null)); + assertThrows(IllegalArgumentException.class, () -> schemaService.addFields("col", List.of())); + } + + @Test + void addFields_happyPath_buildsMultiUpdateAndEchoesNames() throws Exception { + List> fields = List.of( + Map.of("name", "title", "type", "text_general", "stored", true, "indexed", true), + Map.of("name", "platform", "type", "string", "stored", true, "indexed", true, "docValues", true)); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new NamedList<>()); + + SchemaUpdateResult result = schemaService.addFields("col", fields); + + assertTrue(result.success()); + assertEquals(List.of("title", "platform"), result.addedNames()); + assertEquals("col", result.collection()); + assertNotNull(result.timestamp()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SolrRequest.class); + verify(solrClient).request(captor.capture(), eq("col")); + assertInstanceOf(MultiUpdate.class, captor.getValue()); + } + + @Test + void addFields_solrThrows_propagates() throws Exception { + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenThrow(new SolrServerException("simulated")); + + assertThrows(SolrServerException.class, + () -> schemaService.addFields("col", List.of(Map.of("name", "x", "type", "string")))); + } } From e0bd7787b8d54cb083321a0f3aaf71433ae895b0 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 01:40:08 -0400 Subject: [PATCH 06/27] chore(schema): cast field name directly instead of String.valueOf String.valueOf(null) returns the literal "null"; direct cast yields a real null on missing key, which makes the result honest about input shape (Solr's error surfaces before any result is returned). Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../java/org/apache/solr/mcp/server/schema/SchemaService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a775c34d..881bcdb8 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 @@ -284,7 +284,7 @@ public SchemaUpdateResult addFields(@McpToolParam(description = "Solr collection List names = new ArrayList<>(fields.size()); List updates = new ArrayList<>(fields.size()); for (Map field : fields) { - names.add(String.valueOf(field.get("name"))); + names.add((String) field.get("name")); updates.add(new SchemaRequest.AddField(field)); } From bf7173437aedcbc0be268aa2f02cea1ea0ccc671 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 01:42:59 -0400 Subject: [PATCH 07/27] feat(schema): add add-field-types MCP tool with FieldTypeDefinition helper Supports single analyzer, separate index/query analyzers, and non-analyzer field types like DenseVectorField. Manual conversion from flat input map to SolrJ FieldTypeDefinition because name/class go into attributes map and analyzers are typed sub-objects. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../solr/mcp/server/schema/SchemaService.java | 63 ++++++++++++++ .../mcp/server/schema/SchemaServiceTest.java | 84 +++++++++++++++++++ 2 files changed, 147 insertions(+) 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 881bcdb8..b55e6e04 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 @@ -23,10 +23,13 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; +import org.apache.solr.client.solrj.request.schema.AnalyzerDefinition; +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.springaicommunity.mcp.annotation.McpResource; @@ -292,6 +295,66 @@ public SchemaUpdateResult addFields(@McpToolParam(description = "Solr collection return new SchemaUpdateResult(collection, true, names, new Date()); } + @PreAuthorize("isAuthenticated()") + @McpTool(name = "add-field-types", 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. " + + "Commands run in input order; partial application possible on failure.") + 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) + throws SolrServerException, IOException { + requireCollection(collection); + requireNonEmpty(fieldTypes, "fieldTypes"); + + List names = new ArrayList<>(fieldTypes.size()); + List updates = new ArrayList<>(fieldTypes.size()); + for (Map fieldType : fieldTypes) { + names.add((String) fieldType.get("name")); + updates.add(new SchemaRequest.AddFieldType(toFieldTypeDefinition(fieldType))); + } + + new SchemaRequest.MultiUpdate(updates).process(solrClient, collection); + return new SchemaUpdateResult(collection, true, names, new Date()); + } + + /** + * Builds a {@link FieldTypeDefinition} from a flat input map matching the Solr + * Schema API add-field-type JSON shape. SolrJ's {@code FieldTypeDefinition} + * stores name/class and other scalar attributes inside an attributes map, with + * analyzers pulled into typed sub-objects — so we can't deserialize the flat + * input directly via Jackson. + */ + private FieldTypeDefinition toFieldTypeDefinition(Map input) { + FieldTypeDefinition def = new FieldTypeDefinition(); + Map attributes = new LinkedHashMap<>(input); + Object analyzer = attributes.remove("analyzer"); + Object indexAnalyzer = attributes.remove("indexAnalyzer"); + Object queryAnalyzer = attributes.remove("queryAnalyzer"); + def.setAttributes(attributes); + if (analyzer != null) { + def.setAnalyzer(toAnalyzerDefinition(analyzer)); + } + if (indexAnalyzer != null) { + def.setIndexAnalyzer(toAnalyzerDefinition(indexAnalyzer)); + } + if (queryAnalyzer != null) { + def.setQueryAnalyzer(toAnalyzerDefinition(queryAnalyzer)); + } + return def; + } + + private AnalyzerDefinition toAnalyzerDefinition(Object raw) { + return objectMapper.convertValue(raw, AnalyzerDefinition.class); + } + private static void requireCollection(String collection) { if (collection == null || collection.isBlank()) { throw new IllegalArgumentException("Collection name must not be blank"); 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 26ea0c57..09f2854e 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 @@ -200,4 +200,88 @@ void addFields_solrThrows_propagates() throws Exception { assertThrows(SolrServerException.class, () -> schemaService.addFields("col", List.of(Map.of("name", "x", "type", "string")))); } + + @Test + void addFieldTypes_blankCollection_throws() { + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFieldTypes(null, List.of(Map.of("name", "x", "class", "solr.StrField")))); + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFieldTypes("", List.of(Map.of("name", "x", "class", "solr.StrField")))); + } + + @Test + void addFieldTypes_emptyList_throws() { + assertThrows(IllegalArgumentException.class, () -> schemaService.addFieldTypes("col", null)); + assertThrows(IllegalArgumentException.class, () -> schemaService.addFieldTypes("col", List.of())); + } + + @Test + void addFieldTypes_happyPathWithAnalyzer_buildsCorrectFieldTypeDefinition() throws Exception { + // Use a real ObjectMapper so convertValue actually works + ObjectMapper realMapper = new ObjectMapper(); + SchemaService service = new SchemaService(solrClient, realMapper); + + List> types = List.of(Map.of("name", "text_lowercase", "class", "solr.TextField", + "analyzer", Map.of("tokenizer", Map.of("class", "solr.KeywordTokenizerFactory"), "filters", + List.of(Map.of("class", "solr.LowerCaseFilterFactory"))))); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new NamedList<>()); + + SchemaUpdateResult result = service.addFieldTypes("col", types); + + assertTrue(result.success()); + assertEquals(List.of("text_lowercase"), result.addedNames()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SolrRequest.class); + verify(solrClient).request(captor.capture(), eq("col")); + assertInstanceOf(MultiUpdate.class, captor.getValue()); + } + + @Test + void addFieldTypes_separateAnalyzers_buildsCorrectFieldTypeDefinition() throws Exception { + ObjectMapper realMapper = new ObjectMapper(); + SchemaService service = new SchemaService(solrClient, realMapper); + + List> types = List.of(Map.of("name", "text_autocomplete", "class", "solr.TextField", + "indexAnalyzer", + Map.of("tokenizer", Map.of("class", "solr.KeywordTokenizerFactory"), "filters", + List.of(Map.of("class", "solr.LowerCaseFilterFactory"), + Map.of("class", "solr.EdgeNGramFilterFactory", "minGramSize", 2, "maxGramSize", 20))), + "queryAnalyzer", Map.of("tokenizer", Map.of("class", "solr.KeywordTokenizerFactory"), "filters", + List.of(Map.of("class", "solr.LowerCaseFilterFactory"))))); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new NamedList<>()); + + SchemaUpdateResult result = service.addFieldTypes("col", types); + + assertTrue(result.success()); + assertEquals(List.of("text_autocomplete"), result.addedNames()); + } + + @Test + void addFieldTypes_denseVectorField_noAnalyzer() throws Exception { + ObjectMapper realMapper = new ObjectMapper(); + SchemaService service = new SchemaService(solrClient, realMapper); + + List> types = List.of(Map.of("name", "openai_embedding", "class", "solr.DenseVectorField", + "vectorDimension", 1536, "similarityFunction", "cosine", "knnAlgorithm", "hnsw")); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new NamedList<>()); + + SchemaUpdateResult result = service.addFieldTypes("col", types); + + assertTrue(result.success()); + assertEquals(List.of("openai_embedding"), result.addedNames()); + } + + @Test + void addFieldTypes_solrThrows_propagates() throws Exception { + ObjectMapper realMapper = new ObjectMapper(); + SchemaService service = new SchemaService(solrClient, realMapper); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenThrow(new SolrServerException("simulated")); + + assertThrows(SolrServerException.class, + () -> service.addFieldTypes("col", List.of(Map.of("name", "x", "class", "solr.StrField")))); + } } From 2c57bc380b883dfc0291a87ccf51d38b5cbd137e Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 11:13:50 -0400 Subject: [PATCH 08/27] test(schema): add whitespace-only collection assertion to addFieldTypes test Aligns addFieldTypes_blankCollection_throws with the parallel addFields_blankCollection_throws test which already covers null + empty + whitespace. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../org/apache/solr/mcp/server/schema/SchemaServiceTest.java | 2 ++ 1 file changed, 2 insertions(+) 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 09f2854e..fd798db8 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 @@ -207,6 +207,8 @@ void addFieldTypes_blankCollection_throws() { () -> schemaService.addFieldTypes(null, List.of(Map.of("name", "x", "class", "solr.StrField")))); assertThrows(IllegalArgumentException.class, () -> schemaService.addFieldTypes("", List.of(Map.of("name", "x", "class", "solr.StrField")))); + assertThrows(IllegalArgumentException.class, + () -> schemaService.addFieldTypes(" ", List.of(Map.of("name", "x", "class", "solr.StrField")))); } @Test From 3504dd588341a833c7d23410a21b391008fb4514 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 11:14:31 -0400 Subject: [PATCH 09/27] feat(config): register SchemaUpdateResult for GraalVM native image reflection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as the other @McpTool response records — invisible to AOT because MCP dispatches via Object. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../java/org/apache/solr/mcp/server/config/SolrNativeHints.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java index 4dda254b..fec06e7a 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java @@ -67,7 +67,7 @@ public class SolrNativeHints { "org.apache.solr.mcp.server.collection.FieldStats", "org.apache.solr.mcp.server.collection.QueryStats", "org.apache.solr.mcp.server.collection.CacheStats", "org.apache.solr.mcp.server.collection.CacheInfo", "org.apache.solr.mcp.server.collection.HandlerStats", "org.apache.solr.mcp.server.collection.HandlerInfo", - "org.apache.solr.mcp.server.search.SearchResponse"); + "org.apache.solr.mcp.server.search.SearchResponse", "org.apache.solr.mcp.server.schema.SchemaUpdateResult"); static class Registrar implements RuntimeHintsRegistrar { @Override From 4f367a908c110d88cc021d43e23d2c3eaf80d127 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 11:19:02 -0400 Subject: [PATCH 10/27] test(schema): integration tests for add-fields and add-field-types End-to-end against real Solr via Testcontainers. Verifies schema round-trip, custom analyzer behavior, vector field type registration, and error propagation on duplicate field / unknown type. SolrJ's MultiUpdate.process() throws natively on Schema API errors, so no explicit response-body inspection was needed in SchemaService. Note: SolrJ 10 moved SolrQuery to org.apache.solr.client.solrj.request.SolrQuery; vectorDimension attribute is returned as String by the schema API, handled via toString/parseInt. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../schema/SchemaServiceIntegrationTest.java | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java index 439aa762..0b39d26a 100644 --- a/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java @@ -18,9 +18,15 @@ import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import java.util.Map; import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.client.solrj.request.CollectionAdminRequest; +import org.apache.solr.client.solrj.request.SolrQuery; +import org.apache.solr.client.solrj.response.QueryResponse; import org.apache.solr.client.solrj.response.schema.SchemaRepresentation; +import org.apache.solr.common.SolrInputDocument; import org.apache.solr.mcp.server.TestcontainersConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Tag; @@ -174,4 +180,94 @@ void testGetSchema_ReturnsUniqueKey() throws Exception { assertNotNull(schema.getUniqueKey(), "Unique key should be accessible"); } } + + @Test + void addFields_endToEnd_persistsToSchema() throws Exception { + List> fields = List.of( + Map.of("name", "addf_title", "type", "text_general", "stored", true, "indexed", true), + Map.of("name", "addf_platform", "type", "string", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "addf_year", "type", "pint", "stored", true, "indexed", true, "docValues", true)); + + SchemaUpdateResult result = schemaService.addFields(TEST_COLLECTION, fields); + + assertTrue(result.success()); + assertEquals(List.of("addf_title", "addf_platform", "addf_year"), result.addedNames()); + + SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); + Map title = schema.getFields().stream().filter(f -> "addf_title".equals(f.get("name"))) + .findFirst().orElseThrow(); + Map platform = schema.getFields().stream().filter(f -> "addf_platform".equals(f.get("name"))) + .findFirst().orElseThrow(); + Map year = schema.getFields().stream().filter(f -> "addf_year".equals(f.get("name"))) + .findFirst().orElseThrow(); + + assertEquals("text_general", title.get("type")); + assertEquals("string", platform.get("type")); + assertEquals(Boolean.TRUE, platform.get("docValues")); + assertEquals("pint", year.get("type")); + } + + @Test + void addFieldTypes_customAnalyzer_appliesAtIndexAndQuery() throws Exception { + schemaService.addFieldTypes(TEST_COLLECTION, + List.of(Map.of("name", "aft_text_ci_keyword", "class", "solr.TextField", "analyzer", + Map.of("tokenizer", Map.of("class", "solr.KeywordTokenizerFactory"), "filters", + List.of(Map.of("class", "solr.LowerCaseFilterFactory")))))); + + schemaService.addFields(TEST_COLLECTION, List + .of(Map.of("name", "aft_ci_platform", "type", "aft_text_ci_keyword", "stored", true, "indexed", true))); + + SolrInputDocument doc = new SolrInputDocument(); + doc.addField("id", "aft-doc-1"); + doc.addField("aft_ci_platform", "NetFlix"); + solrClient.add(TEST_COLLECTION, doc); + solrClient.commit(TEST_COLLECTION); + + QueryResponse lowercase = solrClient.query(TEST_COLLECTION, new SolrQuery("aft_ci_platform:netflix")); + assertEquals(1L, lowercase.getResults().getNumFound()); + + QueryResponse uppercase = solrClient.query(TEST_COLLECTION, new SolrQuery("aft_ci_platform:NETFLIX")); + assertEquals(1L, uppercase.getResults().getNumFound()); + + QueryResponse partial = solrClient.query(TEST_COLLECTION, new SolrQuery("aft_ci_platform:net")); + assertEquals(0L, partial.getResults().getNumFound()); + } + + @Test + void addFieldTypes_denseVectorField_schemaRoundTrip() throws Exception { + schemaService.addFieldTypes(TEST_COLLECTION, List.of(Map.of("name", "aft_test_vector", "class", + "solr.DenseVectorField", "vectorDimension", 4, "similarityFunction", "cosine"))); + + SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); + Map vt = schema.getFieldTypes().stream() + .filter(t -> "aft_test_vector".equals(t.getAttributes().get("name"))).findFirst().orElseThrow() + .getAttributes(); + + assertEquals("solr.DenseVectorField", vt.get("class")); + Object vectorDimension = vt.get("vectorDimension"); + int vectorDimensionInt = vectorDimension instanceof Number n + ? n.intValue() + : Integer.parseInt(vectorDimension.toString()); + assertEquals(4, vectorDimensionInt); + assertEquals("cosine", vt.get("similarityFunction")); + } + + @Test + void addFields_duplicateField_throws() throws Exception { + List> field = List + .of(Map.of("name", "addf_dup_field", "type", "string", "stored", true, "indexed", true)); + schemaService.addFields(TEST_COLLECTION, field); + + // Second call with same name — Solr returns an error in the response body. + // Whether SolrJ throws or returns silently is part of what this test verifies. + Exception ex = assertThrows(Exception.class, () -> schemaService.addFields(TEST_COLLECTION, field)); + assertTrue(ex instanceof SolrServerException || ex instanceof RuntimeException, + "Expected SolrServerException or RuntimeException, got " + ex.getClass()); + } + + @Test + void addFields_unknownType_throws() { + List> field = List.of(Map.of("name", "addf_broken", "type", "totally_not_a_real_type")); + assertThrows(Exception.class, () -> schemaService.addFields(TEST_COLLECTION, field)); + } } From 2034c583f8129b14c180b6c0587866fd677196be Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 11:24:25 -0400 Subject: [PATCH 11/27] test: extend MCP client integration tests for schema modification tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ordered tests 16-18 exercising the add-fields → index → search workflow through the MCP protocol against both HTTP and stdio transports. Also asserts add-fields and add-field-types appear in listTools output. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../server/McpClientIntegrationTestBase.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 776d52a0..18393654 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java @@ -86,6 +86,8 @@ void listToolsReturnsExpectedTools() { assertTrue(toolNames.contains("check-health"), "Should have check-health tool"); assertTrue(toolNames.contains("get-collection-stats"), "Should have get-collection-stats tool"); assertTrue(toolNames.contains("get-schema"), "Should have get-schema tool"); + assertTrue(toolNames.contains("add-fields"), "Should have add-fields tool"); + assertTrue(toolNames.contains("add-field-types"), "Should have add-field-types tool"); } @Test @@ -279,6 +281,59 @@ void searchFindsAllDocumentsAfterCsvIndexing() throws Exception { assertEquals(7, getNumFound(response), "Should find 7 documents (5 JSON + 2 CSV)"); } + @Test + @Order(16) + void addFieldsToTestCollection() throws Exception { + List> fields = List.of( + Map.of("name", "platform", "type", "string", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "release_year", "type", "pint", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "genres", "type", "strings", "stored", true, "indexed", true, "docValues", true)); + + CallToolResult result = mcpClient + .callTool(new CallToolRequest("add-fields", Map.of("collection", COLLECTION, "fields", fields))); + + assertNotNull(result); + assertNotError(result); + String text = extractText(result); + assertTrue(text.contains("platform"), "Result should mention added 'platform': " + text); + assertTrue(text.contains("release_year"), "Result should mention added 'release_year': " + text); + assertTrue(text.contains("genres"), "Result should mention added 'genres': " + text); + } + + @Test + @Order(17) + void indexDocumentWithNewFields() { + String json = """ + [ + {"id": "show-1", "title": "Breaking Bad", "author": "Vince Gilligan", + "category": "show", "platform": "Netflix", + "release_year": 2008, "genres": ["drama", "crime"]} + ] + """; + + CallToolResult result = mcpClient + .callTool(new CallToolRequest("index-json-documents", Map.of("collection", COLLECTION, "json", json))); + + assertNotNull(result); + assertNotError(result); + } + + @Test + @Order(18) + void searchWithNewFieldFilters() throws Exception { + CallToolResult byPlatform = mcpClient.callTool(new CallToolRequest("search", + Map.of("collection", COLLECTION, "query", "*:*", "filterQueries", List.of("platform:Netflix")))); + Map r1 = OBJECT_MAPPER.readValue(extractText(byPlatform), new TypeReference<>() { + }); + assertEquals(1, getNumFound(r1), "Should find exactly 1 doc with platform=Netflix"); + + CallToolResult byGenre = mcpClient.callTool(new CallToolRequest("search", + Map.of("collection", COLLECTION, "query", "*:*", "filterQueries", List.of("genres:crime")))); + Map r2 = OBJECT_MAPPER.readValue(extractText(byGenre), new TypeReference<>() { + }); + assertEquals(1, getNumFound(r2), "Multi-valued 'genres' should match on 'crime'"); + } + 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"); From 87ca85d0020ca364a3bea678b9129bc136009e86 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 11:26:10 -0400 Subject: [PATCH 12/27] docs: document add-fields and add-field-types MCP tools in README Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7bc57401..20915647 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,8 @@ For complete setup instructions, see [security-docs/AUTH0_SETUP.md](security-doc | Tool | Description | |------|-------------| +| `add-field-types` | Add one or more field types to a Solr collection schema (supports custom analyzers, DenseVectorField for semantic search, etc.) | +| `add-fields` | Add one or more fields to a Solr collection schema (additive only; existing fields cannot be modified) | | `get-schema` | Retrieve schema information for a collection | ## Available MCP Resources From 188d76010982ca47382cc07b22a56bfcb449d931 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 11:26:23 -0400 Subject: [PATCH 13/27] docs: update CLAUDE.md SchemaService entry for new schema-modification tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflects the metadata→schema package rename and the new add-fields and add-field-types capabilities. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8e0d31bc..b7c8bbaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,7 +98,7 @@ Four service classes expose MCP tools via `@McpTool` annotations: - **SearchService** (`search/`) - Full-text search with filtering, faceting, sorting, pagination - **IndexingService** (`indexing/`) - Document indexing supporting JSON, CSV, XML formats - **CollectionService** (`metadata/`) - List collections, get stats, health checks -- **SchemaService** (`metadata/`) - Schema introspection +- **SchemaService** (`schema/`) - Schema introspection and additive modification (add-fields, add-field-types) ### Document Creators (Strategy Pattern) From dc285e703cef2b55cd5e039ce05672e695bd773c Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Sun, 17 May 2026 11:57:50 -0400 Subject: [PATCH 14/27] fix(native): register AnalyzerDefinition and FieldTypeDefinition for reflection GraalVM native test caught that Jackson's convertValue(map, AnalyzerDefinition.class) in SchemaService.toAnalyzerDefinition fails at runtime without reflection metadata. The spec anticipated this; adding both SolrJ types defensively. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../org/apache/solr/mcp/server/config/SolrNativeHints.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java index fec06e7a..f5057b3b 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java @@ -91,6 +91,13 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) hints.reflection().registerType(FacetField.class, categories); hints.reflection().registerType(FacetField.Count.class, categories); + // SolrJ schema request types (needed for Jackson's convertValue in native + // image) + hints.reflection().registerType(org.apache.solr.client.solrj.request.schema.AnalyzerDefinition.class, + categories); + hints.reflection().registerType(org.apache.solr.client.solrj.request.schema.FieldTypeDefinition.class, + categories); + // MCP tool response records (package-private, registered by name) for (String className : MCP_RESPONSE_RECORDS) { hints.reflection().registerTypeIfPresent(classLoader, className, categories); From d4dbb32bfd1449f7a8c9116104098b7b0a96d1a5 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Mon, 18 May 2026 12:10:19 -0400 Subject: [PATCH 15/27] fix(native): register SchemaRepresentation for reflection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The get-schema MCP tool returns SolrJ's SchemaRepresentation, which Spring AI serializes to JSON for MCP clients. Without reflection metadata in the native image, the JSON Spring AI produces is missing the fields/fieldTypes/dynamicFields/copyFields arrays — silently breaking any consumer that introspects the schema. JVM tests didn't catch this because the pre-existing get-schema test only asserts the response is non-empty. The new end-to-end shows workflow in the next commit parses the schema JSON, which surfaced the gap. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../apache/solr/mcp/server/config/SolrNativeHints.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java index f5057b3b..b5ad15d6 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrNativeHints.java @@ -92,12 +92,20 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) hints.reflection().registerType(FacetField.Count.class, categories); // SolrJ schema request types (needed for Jackson's convertValue in native - // image) + // image when add-field-types deserializes analyzer trees) hints.reflection().registerType(org.apache.solr.client.solrj.request.schema.AnalyzerDefinition.class, categories); hints.reflection().registerType(org.apache.solr.client.solrj.request.schema.FieldTypeDefinition.class, categories); + // SolrJ schema response type — returned by the get-schema MCP tool and + // serialized to JSON by Spring AI for MCP clients. Without reflection + // hints the JSON Spring AI produces in native image is missing the + // fields/fieldTypes/dynamicFields/copyFields arrays, which silently + // breaks any consumer that introspects the schema. + hints.reflection().registerType(org.apache.solr.client.solrj.response.schema.SchemaRepresentation.class, + categories); + // MCP tool response records (package-private, registered by name) for (String className : MCP_RESPONSE_RECORDS) { hints.reflection().registerTypeIfPresent(classLoader, className, categories); From f3fbb427871d65451cdae91500b0488e048d7183 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Mon, 18 May 2026 12:10:32 -0400 Subject: [PATCH 16/27] test: end-to-end shows workflow with 61-doc realistic dataset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 9 new ordered MCP-protocol tests (orders 19-27) covering the full workflow an LLM would drive against the shows-collection use case from issue #30: 1. create-collection → "shows" 2. add-fields → 16 user-defined fields (title, platform, genres, release_year, ..., imdb_rating, description, tags) 3. index-json-documents → 61 docs from src/test/resources/shows.json (Netflix, Prime Video, HBO Max, Disney+, Apple TV+, Hulu, Peacock, Paramount+) 4. search → numFound=61 5. search + facet → platform facet returns Netflix=20, Prime=20 6. search + filter → multi-valued genres:Sci-Fi 7. search + keyword → description:apocalyptic with platform filter 8. get-schema → all 16 added fields present 9. get-collection-stats → numDocs=61 Because the test base class is reused by both HTTP and stdio MCP client transports and is also compiled into the GraalVM native test binary, these tests exercise all four combinations: - JVM + stdio (McpClientStdioIntegrationTest) - JVM + HTTP (McpClientIntegrationTest) - Native + stdio (via nativeTest -Pnative) - Native + HTTP (via nativeTest -Pnative) The shows collection inherits the same _default configset that prior tests in this class have already modified via schemaless indexing and add-fields against mcp-client-test. addShowsSchema() calls get-schema first to filter the desired field list to only the fields not already present in the shared configset's managed-schema — which is exactly what the add-fields tool description tells the LLM to do. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../server/McpClientIntegrationTestBase.java | 248 ++++ src/test/resources/shows.json | 1161 +++++++++++++++++ 2 files changed, 1409 insertions(+) create mode 100644 src/test/resources/shows.json 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 18393654..e260eb3a 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java @@ -24,8 +24,11 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.TextContent; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; +import java.util.Objects; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.MethodOrderer; @@ -47,6 +50,15 @@ public abstract class McpClientIntegrationTestBase { protected static final String COLLECTION = "mcp-client-test"; + protected static final String SHOWS_COLLECTION = "shows"; + + /** + * Number of documents in {@code src/test/resources/shows.json} — used by the + * end-to-end shows workflow tests (orders 19–27) to assert indexing and search + * results. + */ + protected static final int SHOWS_DOC_COUNT = 61; + protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); protected McpSyncClient mcpClient; @@ -334,6 +346,242 @@ void searchWithNewFieldFilters() throws Exception { assertEquals(1, getNumFound(r2), "Multi-valued 'genres' should match on 'crime'"); } + // ===== End-to-end shows workflow (orders 19–27) ===== + // Exercises the canonical "set up a new collection with a defined schema, index + // real data, then search and introspect" workflow purely through MCP tool + // calls. The 61-document dataset lives in src/test/resources/shows.json so the + // LLM-facing JSON payload is realistic (UTF-8 names, nullable end_year for + // ongoing shows, multi-valued genres/cast/creators/tags). This works in all + // four transport × runtime combinations (stdio/HTTP × JVM/native) because each + // step is a pure MCP tool call against an existing tool. + + @Test + @Order(19) + void createShowsCollection() { + CallToolResult result = mcpClient + .callTool(new CallToolRequest("create-collection", Map.of("name", SHOWS_COLLECTION))); + + assertNotNull(result); + assertNotError(result); + String text = extractText(result); + assertTrue(text.contains("success") || text.contains("true"), + "Shows collection creation should succeed: " + text); + } + + @Test + @Order(20) + void addShowsSchema() throws Exception { + // Mirrors the user's original curl example: 16 fields covering text_general, + // string, strings (multi-valued), pint, and pdouble types — the schema the + // JSON dataset is shaped for. + // + // Practical wrinkle: SolrCloud collections sharing the same configset (here + // _default) share the same managed-schema in ZooKeeper. Earlier tests in + // this base class indexed docs into mcp-client-test (some via schemaless + // add-unknown-fields, some via add-fields), and those modifications persist + // to the shared _default configset. So when the freshly-created shows + // collection's schema is read, a subset of the desired fields may already + // exist. The tool description on add-fields tells the LLM to "Call + // get-schema first to inspect existing field configuration before adding" + // — this test does exactly that, then only adds the gap. + List> desiredFields = List.of( + Map.of("name", "title", "type", "text_general", "stored", true, "indexed", true), + Map.of("name", "platform", "type", "string", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "genres", "type", "strings", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "release_year", "type", "pint", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "end_year", "type", "pint", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "status", "type", "string", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "seasons", "type", "pint", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "episodes", "type", "pint", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "creators", "type", "strings", "stored", true, "indexed", true), + Map.of("name", "cast", "type", "strings", "stored", true, "indexed", true), + Map.of("name", "country", "type", "string", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "language", "type", "string", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "rating", "type", "string", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "imdb_rating", "type", "pdouble", "stored", true, "indexed", true, "docValues", true), + Map.of("name", "description", "type", "text_general", "stored", true, "indexed", true), + Map.of("name", "tags", "type", "strings", "stored", true, "indexed", true, "docValues", true)); + + java.util.Set existingFieldNames = fetchExistingFieldNames(SHOWS_COLLECTION); + + List> fieldsToAdd = desiredFields.stream() + .filter(f -> !existingFieldNames.contains(String.valueOf(f.get("name")))).toList(); + + // Edge case: every desired field already exists in the shared configset + // (extremely unlikely given the shows-specific names). Soft-pass; the + // schema is already in the state we want. + if (fieldsToAdd.isEmpty()) { + return; + } + + CallToolResult result = mcpClient.callTool( + new CallToolRequest("add-fields", Map.of("collection", SHOWS_COLLECTION, "fields", fieldsToAdd))); + + assertNotNull(result); + assertNotError(result); + String text = extractText(result); + // Spot-check at least one shows-specific field name appears (these won't + // have leaked from any earlier collection's schema). + assertTrue(text.contains("seasons") || text.contains("episodes") || text.contains("imdb_rating"), + "Result should mention at least one shows-specific field that was added: " + text); + } + + private java.util.Set fetchExistingFieldNames(String collection) throws Exception { + CallToolResult schemaResult = mcpClient + .callTool(new CallToolRequest("get-schema", Map.of("collection", collection))); + assertNotError(schemaResult); + Map schema = OBJECT_MAPPER.readValue(extractText(schemaResult), new TypeReference<>() { + }); + Object fields = schema.get("fields"); + java.util.Set names = new java.util.HashSet<>(); + if (fields instanceof List fieldList) { + for (Object f : fieldList) { + if (f instanceof Map fieldMap) { + Object name = fieldMap.get("name"); + if (name instanceof String s) { + names.add(s); + } + } + } + } + return names; + } + + @Test + @Order(21) + void indexShowsFromClasspathResource() throws Exception { + String showsJson = loadClasspathResource("/shows.json"); + assertFalse(showsJson.isBlank(), "shows.json resource must not be blank"); + + CallToolResult result = mcpClient.callTool( + new CallToolRequest("index-json-documents", Map.of("collection", SHOWS_COLLECTION, "json", showsJson))); + + assertNotNull(result); + assertNotError(result); + } + + @Test + @Order(22) + void searchAllShowsAndAssertCount() throws Exception { + CallToolResult result = mcpClient.callTool( + new CallToolRequest("search", Map.of("collection", SHOWS_COLLECTION, "query", "*:*", "rows", 0))); + + assertNotNull(result); + assertNotError(result); + Map response = OBJECT_MAPPER.readValue(extractText(result), new TypeReference<>() { + }); + assertEquals(SHOWS_DOC_COUNT, getNumFound(response), + "Should find all " + SHOWS_DOC_COUNT + " shows after indexing"); + } + + @Test + @Order(23) + void searchShowsWithFacetByPlatform() throws Exception { + CallToolResult result = mcpClient.callTool(new CallToolRequest("search", + Map.of("collection", SHOWS_COLLECTION, "query", "*:*", "facetFields", List.of("platform"), "rows", 0))); + + assertNotNull(result); + assertNotError(result); + Map response = OBJECT_MAPPER.readValue(extractText(result), new TypeReference<>() { + }); + + @SuppressWarnings("unchecked") + Map facets = (Map) response.get("facets"); + assertNotNull(facets, "Response should contain facets: " + response); + + @SuppressWarnings("unchecked") + Map platformFacet = (Map) facets.get("platform"); + assertNotNull(platformFacet, "facets.platform should be present: " + facets); + + // The dataset has 20 Netflix shows and 20 Amazon Prime Video shows. + Object netflixCount = platformFacet.get("Netflix"); + assertNotNull(netflixCount, "Netflix facet count should be present: " + platformFacet); + assertEquals(20, ((Number) netflixCount).intValue(), "Netflix should facet to 20 shows: " + platformFacet); + + Object primeCount = platformFacet.get("Amazon Prime Video"); + assertNotNull(primeCount, "Amazon Prime Video facet count should be present: " + platformFacet); + assertEquals(20, ((Number) primeCount).intValue(), + "Amazon Prime Video should facet to 20 shows: " + platformFacet); + + assertTrue(platformFacet.containsKey("HBO Max"), "HBO Max should appear in facet: " + platformFacet); + } + + @Test + @Order(24) + void searchShowsWithFilterAndKeyword() throws Exception { + // Filter to one platform, then full-text search the description field. + CallToolResult result = mcpClient.callTool( + new CallToolRequest("search", Map.of("collection", SHOWS_COLLECTION, "query", "description:apocalyptic", + "filterQueries", List.of("platform:\"Amazon Prime Video\""), "rows", 10))); + + assertNotNull(result); + assertNotError(result); + Map response = OBJECT_MAPPER.readValue(extractText(result), new TypeReference<>() { + }); + // "Fallout" on Amazon Prime Video has "post-apocalyptic" in its description. + assertTrue(getNumFound(response) >= 1, + "Should find at least one apocalyptic show on Amazon Prime Video: " + getNumFound(response)); + } + + @Test + @Order(25) + void searchShowsByMultiValuedGenre() throws Exception { + // Multi-valued strings field: filter on a single genre value. + CallToolResult result = mcpClient.callTool(new CallToolRequest("search", Map.of("collection", SHOWS_COLLECTION, + "query", "*:*", "filterQueries", List.of("genres:Sci-Fi"), "rows", 0))); + + assertNotNull(result); + assertNotError(result); + Map response = OBJECT_MAPPER.readValue(extractText(result), new TypeReference<>() { + }); + // At least Stranger Things, Dark, Black Mirror, The Umbrella Academy, The + // Boys, The Expanse, Fallout, Upload, The Last of Us, Westworld, WandaVision, + // The Mandalorian, Andor, Loki, Severance, Foundation, Star Trek SNW — well + // over 10 docs. + assertTrue(getNumFound(response) >= 10, + "Should find at least 10 Sci-Fi shows across the dataset, got " + getNumFound(response)); + } + + @Test + @Order(26) + void getShowsSchemaIncludesAllAddedFields() { + CallToolResult result = mcpClient + .callTool(new CallToolRequest("get-schema", Map.of("collection", SHOWS_COLLECTION))); + + assertNotNull(result); + assertNotError(result); + String text = extractText(result); + // All 16 user-defined fields must appear in the schema response. Spot-check a + // representative selection across types: text_general, string, strings, + // pint, pdouble. + for (String name : List.of("title", "platform", "genres", "release_year", "imdb_rating", "description", + "tags")) { + assertTrue(text.contains("\"" + name + "\""), "get-schema response should include field '" + name + "'"); + } + } + + @Test + @Order(27) + void getShowsCollectionStats() throws Exception { + CallToolResult result = mcpClient + .callTool(new CallToolRequest("get-collection-stats", Map.of("collection", SHOWS_COLLECTION))); + + assertNotNull(result); + assertNotError(result); + String text = extractText(result); + // Stats response is a JSON-serialized SolrMetrics. The indexStats.numDocs + // field carries the count. + assertTrue(text.contains(String.valueOf(SHOWS_DOC_COUNT)), + "Stats should report " + SHOWS_DOC_COUNT + " docs somewhere in the payload: " + text); + } + + private static String loadClasspathResource(String resourcePath) throws Exception { + try (InputStream in = McpClientIntegrationTestBase.class.getResourceAsStream(resourcePath)) { + Objects.requireNonNull(in, "Classpath resource not found: " + resourcePath); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } + } + 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"); diff --git a/src/test/resources/shows.json b/src/test/resources/shows.json new file mode 100644 index 00000000..5a3ca465 --- /dev/null +++ b/src/test/resources/shows.json @@ -0,0 +1,1161 @@ +[ + { + "id": "netflix-001", + "title": "Stranger Things", + "platform": "Netflix", + "genres": ["Sci-Fi", "Horror", "Drama"], + "release_year": 2016, + "end_year": 2025, + "status": "Ended", + "seasons": 5, + "episodes": 42, + "creators": ["Matt Duffer", "Ross Duffer"], + "cast": ["Millie Bobby Brown", "Finn Wolfhard", "Winona Ryder", "David Harbour"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 8.7, + "description": "A group of kids in 1980s Indiana uncover supernatural mysteries and government conspiracies tied to a parallel dimension.", + "tags": ["80s", "supernatural", "coming-of-age", "monsters"] + }, + { + "id": "netflix-002", + "title": "The Crown", + "platform": "Netflix", + "genres": ["Drama", "Historical", "Biography"], + "release_year": 2016, + "end_year": 2023, + "status": "Ended", + "seasons": 6, + "episodes": 60, + "creators": ["Peter Morgan"], + "cast": ["Claire Foy", "Olivia Colman", "Imelda Staunton", "Matt Smith"], + "country": "UK", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.6, + "description": "The reign of Queen Elizabeth II from her wedding in 1947 through the early 21st century.", + "tags": ["royalty", "british", "period-drama", "politics"] + }, + { + "id": "netflix-003", + "title": "Squid Game", + "platform": "Netflix", + "genres": ["Thriller", "Drama", "Survival"], + "release_year": 2021, + "end_year": 2025, + "status": "Ended", + "seasons": 3, + "episodes": 22, + "creators": ["Hwang Dong-hyuk"], + "cast": ["Lee Jung-jae", "Park Hae-soo", "Wi Ha-joon", "HoYeon Jung"], + "country": "South Korea", + "language": "Korean", + "rating": "TV-MA", + "imdb_rating": 8.0, + "description": "Hundreds of cash-strapped contestants accept an invitation to compete in deadly children's games for a tempting prize.", + "tags": ["korean", "survival", "dystopian", "social-commentary"] + }, + { + "id": "netflix-004", + "title": "Wednesday", + "platform": "Netflix", + "genres": ["Comedy", "Horror", "Mystery"], + "release_year": 2022, + "end_year": null, + "status": "Ongoing", + "seasons": 2, + "episodes": 16, + "creators": ["Alfred Gough", "Miles Millar"], + "cast": ["Jenna Ortega", "Catherine Zeta-Jones", "Luis Guzmán", "Gwendoline Christie"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 8.1, + "description": "Wednesday Addams navigates a supernatural boarding school while solving a murder mystery.", + "tags": ["gothic", "teen", "supernatural", "addams-family"] + }, + { + "id": "netflix-005", + "title": "Money Heist", + "platform": "Netflix", + "genres": ["Crime", "Thriller", "Drama"], + "release_year": 2017, + "end_year": 2021, + "status": "Ended", + "seasons": 5, + "episodes": 41, + "creators": ["Álex Pina"], + "cast": ["Úrsula Corberó", "Álvaro Morte", "Pedro Alonso", "Itziar Ituño"], + "country": "Spain", + "language": "Spanish", + "rating": "TV-MA", + "imdb_rating": 8.2, + "description": "An unusual group of robbers attempt to carry out the most perfect robbery in Spanish history.", + "tags": ["spanish", "heist", "crime", "ensemble"] + }, + { + "id": "netflix-006", + "title": "Dark", + "platform": "Netflix", + "genres": ["Sci-Fi", "Mystery", "Thriller"], + "release_year": 2017, + "end_year": 2020, + "status": "Ended", + "seasons": 3, + "episodes": 26, + "creators": ["Baran bo Odar", "Jantje Friese"], + "cast": ["Louis Hofmann", "Karoline Eichhorn", "Lisa Vicari", "Maja Schöne"], + "country": "Germany", + "language": "German", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "A family saga with a supernatural twist set in a German town where the disappearance of children exposes the relationships among four families.", + "tags": ["time-travel", "german", "complex-plot", "mystery"] + }, + { + "id": "netflix-007", + "title": "Ozark", + "platform": "Netflix", + "genres": ["Crime", "Drama", "Thriller"], + "release_year": 2017, + "end_year": 2022, + "status": "Ended", + "seasons": 4, + "episodes": 44, + "creators": ["Bill Dubuque", "Mark Williams"], + "cast": ["Jason Bateman", "Laura Linney", "Julia Garner", "Sofia Hublitz"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.5, + "description": "A financial advisor drags his family from Chicago to the Missouri Ozarks, where he must launder money to appease a drug boss.", + "tags": ["money-laundering", "cartel", "family-drama", "crime"] + }, + { + "id": "netflix-008", + "title": "Black Mirror", + "platform": "Netflix", + "genres": ["Sci-Fi", "Drama", "Anthology"], + "release_year": 2011, + "end_year": null, + "status": "Ongoing", + "seasons": 7, + "episodes": 33, + "creators": ["Charlie Brooker"], + "cast": ["Various"], + "country": "UK", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "An anthology series exploring a twisted, high-tech multiverse where humanity's greatest innovations collide with its darkest instincts.", + "tags": ["anthology", "technology", "dystopian", "british"] + }, + { + "id": "netflix-009", + "title": "The Witcher", + "platform": "Netflix", + "genres": ["Fantasy", "Action", "Adventure"], + "release_year": 2019, + "end_year": null, + "status": "Ongoing", + "seasons": 4, + "episodes": 32, + "creators": ["Lauren Schmidt Hissrich"], + "cast": ["Henry Cavill", "Liam Hemsworth", "Anya Chalotra", "Freya Allan"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.0, + "description": "Geralt of Rivia, a solitary monster hunter, struggles to find his place in a world where people often prove more wicked than beasts.", + "tags": ["fantasy", "monsters", "magic", "adaptation"] + }, + { + "id": "netflix-010", + "title": "Bridgerton", + "platform": "Netflix", + "genres": ["Romance", "Drama", "Historical"], + "release_year": 2020, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 24, + "creators": ["Chris Van Dusen"], + "cast": ["Phoebe Dynevor", "Regé-Jean Page", "Jonathan Bailey", "Nicola Coughlan"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 7.3, + "description": "Wealth, lust, and betrayal set against the backdrop of Regency-era England, seen through the eyes of the powerful Bridgerton family.", + "tags": ["regency", "romance", "period-drama", "shondaland"] + }, + { + "id": "netflix-011", + "title": "Narcos", + "platform": "Netflix", + "genres": ["Crime", "Biography", "Drama"], + "release_year": 2015, + "end_year": 2017, + "status": "Ended", + "seasons": 3, + "episodes": 30, + "creators": ["Chris Brancato", "Carlo Bernard", "Doug Miro"], + "cast": ["Wagner Moura", "Pedro Pascal", "Boyd Holbrook"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.8, + "description": "The true story of Colombia's infamously violent and powerful drug cartels.", + "tags": ["cartel", "colombia", "true-crime", "pablo-escobar"] + }, + { + "id": "netflix-012", + "title": "Mindhunter", + "platform": "Netflix", + "genres": ["Crime", "Drama", "Thriller"], + "release_year": 2017, + "end_year": 2019, + "status": "Ended", + "seasons": 2, + "episodes": 19, + "creators": ["Joe Penhall"], + "cast": ["Jonathan Groff", "Holt McCallany", "Anna Torv"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.6, + "description": "Two FBI agents interview imprisoned serial killers to apply behavioral science to their open cases.", + "tags": ["fbi", "serial-killers", "psychology", "fincher"] + }, + { + "id": "netflix-013", + "title": "The Queen's Gambit", + "platform": "Netflix", + "genres": ["Drama", "Period"], + "release_year": 2020, + "end_year": 2020, + "status": "Ended", + "seasons": 1, + "episodes": 7, + "creators": ["Scott Frank", "Allan Scott"], + "cast": ["Anya Taylor-Joy", "Bill Camp", "Marielle Heller"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.5, + "description": "An orphaned chess prodigy rises to the top of the chess world while struggling with addiction.", + "tags": ["chess", "limited-series", "cold-war", "addiction"] + }, + { + "id": "netflix-014", + "title": "BoJack Horseman", + "platform": "Netflix", + "genres": ["Animation", "Comedy", "Drama"], + "release_year": 2014, + "end_year": 2020, + "status": "Ended", + "seasons": 6, + "episodes": 77, + "creators": ["Raphael Bob-Waksberg"], + "cast": ["Will Arnett", "Aaron Paul", "Amy Sedaris", "Alison Brie"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.8, + "description": "A washed-up actor who happens to be a horse navigates Hollywood life, depression, and existentialism.", + "tags": ["animation", "adult-animation", "satire", "mental-health"] + }, + { + "id": "netflix-015", + "title": "Peaky Blinders", + "platform": "Netflix", + "genres": ["Crime", "Drama", "Historical"], + "release_year": 2013, + "end_year": 2022, + "status": "Ended", + "seasons": 6, + "episodes": 36, + "creators": ["Steven Knight"], + "cast": ["Cillian Murphy", "Paul Anderson", "Helen McCrory", "Tom Hardy"], + "country": "UK", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.8, + "description": "A gangster family epic set in 1900s England, centering on a gang led by the fierce Tommy Shelby.", + "tags": ["british", "gangster", "period-drama", "birmingham"] + }, + { + "id": "netflix-016", + "title": "Lupin", + "platform": "Netflix", + "genres": ["Crime", "Mystery", "Drama"], + "release_year": 2021, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 17, + "creators": ["George Kay", "François Uzan"], + "cast": ["Omar Sy", "Ludivine Sagnier", "Clotilde Hesme"], + "country": "France", + "language": "French", + "rating": "TV-MA", + "imdb_rating": 7.5, + "description": "Inspired by Arsène Lupin, gentleman thief Assane Diop sets out to avenge his father for an injustice inflicted by a wealthy family.", + "tags": ["french", "heist", "revenge", "paris"] + }, + { + "id": "netflix-017", + "title": "The Umbrella Academy", + "platform": "Netflix", + "genres": ["Sci-Fi", "Action", "Drama"], + "release_year": 2019, + "end_year": 2024, + "status": "Ended", + "seasons": 4, + "episodes": 34, + "creators": ["Steve Blackman"], + "cast": ["Elliot Page", "Tom Hopper", "David Castañeda", "Emmy Raver-Lampman"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 7.9, + "description": "A dysfunctional family of adopted sibling superheroes reunites to solve the mystery of their father's death and the threat of an apocalypse.", + "tags": ["superheroes", "time-travel", "comic-adaptation", "family"] + }, + { + "id": "netflix-018", + "title": "Sex Education", + "platform": "Netflix", + "genres": ["Comedy", "Drama", "Romance"], + "release_year": 2019, + "end_year": 2023, + "status": "Ended", + "seasons": 4, + "episodes": 32, + "creators": ["Laurie Nunn"], + "cast": ["Asa Butterfield", "Gillian Anderson", "Emma Mackey", "Ncuti Gatwa"], + "country": "UK", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.3, + "description": "A teenage boy with a sex therapist mother teams up with a high school classmate to set up an underground sex therapy clinic at school.", + "tags": ["teen", "british", "coming-of-age", "lgbtq"] + }, + { + "id": "netflix-019", + "title": "House of Cards", + "platform": "Netflix", + "genres": ["Drama", "Political", "Thriller"], + "release_year": 2013, + "end_year": 2018, + "status": "Ended", + "seasons": 6, + "episodes": 73, + "creators": ["Beau Willimon"], + "cast": ["Kevin Spacey", "Robin Wright", "Michael Kelly"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "A ruthless politician will stop at nothing to conquer Washington, D.C.", + "tags": ["politics", "washington", "dc", "machiavellian"] + }, + { + "id": "netflix-020", + "title": "Beef", + "platform": "Netflix", + "genres": ["Comedy", "Drama", "Thriller"], + "release_year": 2023, + "end_year": 2023, + "status": "Ended", + "seasons": 1, + "episodes": 10, + "creators": ["Lee Sung Jin"], + "cast": ["Steven Yeun", "Ali Wong", "Joseph Lee", "Young Mazino"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.0, + "description": "A road rage incident between two strangers triggers a feud that brings out their darkest impulses.", + "tags": ["limited-series", "dark-comedy", "a24", "asian-american"] + }, + { + "id": "prime-001", + "title": "The Boys", + "platform": "Amazon Prime Video", + "genres": ["Action", "Sci-Fi", "Drama"], + "release_year": 2019, + "end_year": null, + "status": "Ongoing", + "seasons": 4, + "episodes": 32, + "creators": ["Eric Kripke"], + "cast": ["Karl Urban", "Jack Quaid", "Antony Starr", "Erin Moriarty"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "A group of vigilantes set out to take down corrupt superheroes who abuse their powers.", + "tags": ["superheroes", "satire", "violent", "comic-adaptation"] + }, + { + "id": "prime-002", + "title": "The Marvelous Mrs. Maisel", + "platform": "Amazon Prime Video", + "genres": ["Comedy", "Drama", "Period"], + "release_year": 2017, + "end_year": 2023, + "status": "Ended", + "seasons": 5, + "episodes": 43, + "creators": ["Amy Sherman-Palladino"], + "cast": ["Rachel Brosnahan", "Alex Borstein", "Michael Zegen", "Tony Shalhoub"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "A 1950s New York housewife discovers she has a talent for stand-up comedy.", + "tags": ["50s", "stand-up", "feminist", "new-york"] + }, + { + "id": "prime-003", + "title": "Fleabag", + "platform": "Amazon Prime Video", + "genres": ["Comedy", "Drama"], + "release_year": 2016, + "end_year": 2019, + "status": "Ended", + "seasons": 2, + "episodes": 12, + "creators": ["Phoebe Waller-Bridge"], + "cast": ["Phoebe Waller-Bridge", "Sian Clifford", "Olivia Colman", "Andrew Scott"], + "country": "UK", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "A dry-witted woman navigates life and love in London while trying to cope with tragedy.", + "tags": ["british", "dark-comedy", "fourth-wall", "limited-series"] + }, + { + "id": "prime-004", + "title": "The Expanse", + "platform": "Amazon Prime Video", + "genres": ["Sci-Fi", "Drama", "Mystery"], + "release_year": 2015, + "end_year": 2022, + "status": "Ended", + "seasons": 6, + "episodes": 62, + "creators": ["Mark Fergus", "Hawk Ostby"], + "cast": ["Steven Strait", "Dominique Tipper", "Wes Chatham", "Shohreh Aghdashloo"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 8.5, + "description": "In a colonized future solar system, a detective, a ship's officer, and a UN executive uncover a conspiracy.", + "tags": ["space", "hard-sci-fi", "politics", "adaptation"] + }, + { + "id": "prime-005", + "title": "The Lord of the Rings: The Rings of Power", + "platform": "Amazon Prime Video", + "genres": ["Fantasy", "Adventure", "Drama"], + "release_year": 2022, + "end_year": null, + "status": "Ongoing", + "seasons": 2, + "episodes": 16, + "creators": ["J.D. Payne", "Patrick McKay"], + "cast": ["Morfydd Clark", "Robert Aramayo", "Charlie Vickers", "Markella Kavenagh"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 6.9, + "description": "Epic drama set thousands of years before the events of The Hobbit and The Lord of the Rings, follows an ensemble cast in the Second Age of Middle-earth.", + "tags": ["tolkien", "middle-earth", "epic", "high-budget"] + }, + { + "id": "prime-006", + "title": "Reacher", + "platform": "Amazon Prime Video", + "genres": ["Action", "Crime", "Drama"], + "release_year": 2022, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 24, + "creators": ["Nick Santora"], + "cast": ["Alan Ritchson", "Malcolm Goodwin", "Willa Fitzgerald"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.1, + "description": "Veteran military police investigator Jack Reacher solves crimes as a drifter.", + "tags": ["action", "thriller", "lee-child", "adaptation"] + }, + { + "id": "prime-007", + "title": "Jack Ryan", + "platform": "Amazon Prime Video", + "genres": ["Action", "Thriller", "Drama"], + "release_year": 2018, + "end_year": 2023, + "status": "Ended", + "seasons": 4, + "episodes": 32, + "creators": ["Carlton Cuse", "Graham Roland"], + "cast": ["John Krasinski", "Wendell Pierce", "Abbie Cornish"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.0, + "description": "An up-and-coming CIA analyst is thrust into a dangerous field assignment.", + "tags": ["cia", "spy", "tom-clancy", "adaptation"] + }, + { + "id": "prime-008", + "title": "Fallout", + "platform": "Amazon Prime Video", + "genres": ["Sci-Fi", "Action", "Adventure"], + "release_year": 2024, + "end_year": null, + "status": "Ongoing", + "seasons": 1, + "episodes": 8, + "creators": ["Graham Wagner", "Geneva Robertson-Dworet"], + "cast": ["Ella Purnell", "Walton Goggins", "Aaron Moten", "Kyle MacLachlan"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.4, + "description": "In a future, post-apocalyptic Los Angeles brought about by nuclear decimation, citizens must live in underground bunkers to protect themselves.", + "tags": ["post-apocalyptic", "video-game-adaptation", "wasteland", "vaults"] + }, + { + "id": "prime-009", + "title": "Mr. & Mrs. Smith", + "platform": "Amazon Prime Video", + "genres": ["Action", "Comedy", "Romance"], + "release_year": 2024, + "end_year": null, + "status": "Ongoing", + "seasons": 1, + "episodes": 8, + "creators": ["Donald Glover", "Francesca Sloane"], + "cast": ["Donald Glover", "Maya Erskine"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 7.2, + "description": "Two lonely strangers take a job working for a mysterious spy agency that offers them a glamorous life, a dream brownstone in Manhattan, and a new identity as a married couple.", + "tags": ["spy", "romance", "remake", "donald-glover"] + }, + { + "id": "prime-010", + "title": "The Wheel of Time", + "platform": "Amazon Prime Video", + "genres": ["Fantasy", "Adventure", "Action"], + "release_year": 2021, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 24, + "creators": ["Rafe Judkins"], + "cast": ["Rosamund Pike", "Daniel Henney", "Josha Stradowski", "Madeleine Madden"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 7.0, + "description": "Set in a high fantasy world where magic exists, but only some can access it, a woman named Moiraine leads five young people on a journey.", + "tags": ["fantasy", "magic", "robert-jordan", "epic"] + }, + { + "id": "prime-011", + "title": "Good Omens", + "platform": "Amazon Prime Video", + "genres": ["Comedy", "Fantasy", "Drama"], + "release_year": 2019, + "end_year": null, + "status": "Ongoing", + "seasons": 2, + "episodes": 12, + "creators": ["Neil Gaiman"], + "cast": ["David Tennant", "Michael Sheen", "Jon Hamm", "Frances McDormand"], + "country": "UK", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.0, + "description": "A demon and an angel attempt to prevent the apocalypse on Earth, which they have grown rather fond of over the millennia.", + "tags": ["british", "supernatural", "gaiman", "pratchett"] + }, + { + "id": "prime-012", + "title": "The Terminal List", + "platform": "Amazon Prime Video", + "genres": ["Action", "Drama", "Thriller"], + "release_year": 2022, + "end_year": null, + "status": "Ongoing", + "seasons": 1, + "episodes": 8, + "creators": ["David DiGilio"], + "cast": ["Chris Pratt", "Constance Wu", "Taylor Kitsch"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.0, + "description": "A former Navy SEAL officer investigates why his entire platoon was ambushed during a high-stakes covert mission.", + "tags": ["navy-seal", "revenge", "military", "thriller"] + }, + { + "id": "prime-013", + "title": "Upload", + "platform": "Amazon Prime Video", + "genres": ["Sci-Fi", "Comedy", "Mystery"], + "release_year": 2020, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 24, + "creators": ["Greg Daniels"], + "cast": ["Robbie Amell", "Andy Allo", "Allegra Edwards"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 7.9, + "description": "A man is able to choose his own afterlife after his untimely death by having his consciousness uploaded into a virtual world.", + "tags": ["afterlife", "technology", "sci-fi-comedy", "near-future"] + }, + { + "id": "prime-014", + "title": "Bosch", + "platform": "Amazon Prime Video", + "genres": ["Crime", "Drama", "Mystery"], + "release_year": 2014, + "end_year": 2021, + "status": "Ended", + "seasons": 7, + "episodes": 68, + "creators": ["Eric Overmyer"], + "cast": ["Titus Welliver", "Jamie Hector", "Amy Aquino"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.5, + "description": "An LAPD homicide detective works to solve the murder of a 13-year-old boy.", + "tags": ["detective", "los-angeles", "procedural", "michael-connelly"] + }, + { + "id": "prime-015", + "title": "Invincible", + "platform": "Amazon Prime Video", + "genres": ["Animation", "Action", "Drama"], + "release_year": 2021, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 24, + "creators": ["Robert Kirkman"], + "cast": ["Steven Yeun", "J.K. Simmons", "Sandra Oh", "Zazie Beetz"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "An adult animated series about a teenager whose father is the most powerful superhero on the planet.", + "tags": ["adult-animation", "superheroes", "violent", "comic-adaptation"] + }, + { + "id": "prime-016", + "title": "Vikings", + "platform": "Amazon Prime Video", + "genres": ["Drama", "Action", "Historical"], + "release_year": 2013, + "end_year": 2020, + "status": "Ended", + "seasons": 6, + "episodes": 89, + "creators": ["Michael Hirst"], + "cast": ["Travis Fimmel", "Katheryn Winnick", "Clive Standen", "Gustaf Skarsgård"], + "country": "Ireland", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.5, + "description": "The world of the Vikings is brought to life through the journey of Ragnar Lothbrok, the first Viking to emerge from Norse legend.", + "tags": ["vikings", "historical", "norse", "ragnar"] + }, + { + "id": "prime-017", + "title": "The Underground Railroad", + "platform": "Amazon Prime Video", + "genres": ["Drama", "Historical"], + "release_year": 2021, + "end_year": 2021, + "status": "Ended", + "seasons": 1, + "episodes": 10, + "creators": ["Barry Jenkins"], + "cast": ["Thuso Mbedu", "Chase W. Dillon", "Joel Edgerton"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 7.4, + "description": "A young woman makes a harrowing escape from antebellum slavery via an underground railroad that travels in a literal underground train.", + "tags": ["limited-series", "slavery", "barry-jenkins", "literary-adaptation"] + }, + { + "id": "prime-018", + "title": "Hunters", + "platform": "Amazon Prime Video", + "genres": ["Drama", "Thriller", "Action"], + "release_year": 2020, + "end_year": 2023, + "status": "Ended", + "seasons": 2, + "episodes": 18, + "creators": ["David Weil"], + "cast": ["Al Pacino", "Logan Lerman", "Jerrika Hinton", "Lena Olin"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 7.2, + "description": "A diverse band of Nazi hunters living in 1977 New York City discover that hundreds of high-ranking Nazi officials are conspiring to create a Fourth Reich in the U.S.", + "tags": ["nazi-hunters", "70s", "alternate-history", "new-york"] + }, + { + "id": "prime-019", + "title": "Carnival Row", + "platform": "Amazon Prime Video", + "genres": ["Fantasy", "Drama", "Mystery"], + "release_year": 2019, + "end_year": 2023, + "status": "Ended", + "seasons": 2, + "episodes": 18, + "creators": ["René Echevarria", "Travis Beacham"], + "cast": ["Orlando Bloom", "Cara Delevingne", "Simon McBurney"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 7.8, + "description": "Mythical creatures have fled their war-torn homeland and gathered in a city where humans live, leading to escalating tensions.", + "tags": ["fantasy", "victorian", "mythical-creatures", "noir"] + }, + { + "id": "prime-020", + "title": "Citadel", + "platform": "Amazon Prime Video", + "genres": ["Action", "Drama", "Thriller"], + "release_year": 2023, + "end_year": null, + "status": "Ongoing", + "seasons": 1, + "episodes": 6, + "creators": ["Josh Appelbaum", "Bryan Oh", "David Weil"], + "cast": ["Richard Madden", "Priyanka Chopra Jonas", "Stanley Tucci"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 5.9, + "description": "Eight years after the fall of an independent global spy agency, two former agents must work together to stop a new threat.", + "tags": ["spy", "russo-brothers", "global", "amnesia"] + }, + { + "id": "hbo-001", + "title": "Game of Thrones", + "platform": "HBO Max", + "genres": ["Fantasy", "Drama", "Adventure"], + "release_year": 2011, + "end_year": 2019, + "status": "Ended", + "seasons": 8, + "episodes": 73, + "creators": ["David Benioff", "D.B. Weiss"], + "cast": ["Emilia Clarke", "Peter Dinklage", "Kit Harington", "Lena Headey"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 9.2, + "description": "Nine noble families fight for control over the lands of Westeros, while an ancient enemy returns after being dormant for millennia.", + "tags": ["fantasy", "dragons", "epic", "george-rr-martin"] + }, + { + "id": "hbo-002", + "title": "Succession", + "platform": "HBO Max", + "genres": ["Drama", "Comedy"], + "release_year": 2018, + "end_year": 2023, + "status": "Ended", + "seasons": 4, + "episodes": 39, + "creators": ["Jesse Armstrong"], + "cast": ["Brian Cox", "Jeremy Strong", "Kieran Culkin", "Sarah Snook"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.9, + "description": "The Roy family controls the biggest media and entertainment company in the world, but their patriarch's health is failing.", + "tags": ["media", "family-drama", "satire", "wealth"] + }, + { + "id": "hbo-003", + "title": "The Last of Us", + "platform": "HBO Max", + "genres": ["Drama", "Horror", "Sci-Fi"], + "release_year": 2023, + "end_year": null, + "status": "Ongoing", + "seasons": 2, + "episodes": 16, + "creators": ["Craig Mazin", "Neil Druckmann"], + "cast": ["Pedro Pascal", "Bella Ramsey", "Anna Torv"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "After a global pandemic destroys civilization, a hardened survivor takes charge of a 14-year-old girl who may be humanity's last hope.", + "tags": ["post-apocalyptic", "zombies", "video-game-adaptation", "fungal"] + }, + { + "id": "hbo-004", + "title": "House of the Dragon", + "platform": "HBO Max", + "genres": ["Fantasy", "Drama", "Action"], + "release_year": 2022, + "end_year": null, + "status": "Ongoing", + "seasons": 2, + "episodes": 18, + "creators": ["Ryan Condal", "George R.R. Martin"], + "cast": ["Paddy Considine", "Matt Smith", "Emma D'Arcy", "Olivia Cooke"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.4, + "description": "An internal succession war within House Targaryen at the height of its power, 172 years before the birth of Daenerys Targaryen.", + "tags": ["targaryen", "dragons", "got-prequel", "civil-war"] + }, + { + "id": "hbo-005", + "title": "True Detective", + "platform": "HBO Max", + "genres": ["Crime", "Drama", "Mystery"], + "release_year": 2014, + "end_year": null, + "status": "Ongoing", + "seasons": 4, + "episodes": 32, + "creators": ["Nic Pizzolatto"], + "cast": ["Matthew McConaughey", "Woody Harrelson", "Mahershala Ali", "Jodie Foster"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.9, + "description": "An anthology series of police investigations, with each season featuring a new cast and setting.", + "tags": ["anthology", "noir", "detective", "atmospheric"] + }, + { + "id": "hbo-006", + "title": "Westworld", + "platform": "HBO Max", + "genres": ["Sci-Fi", "Drama", "Mystery"], + "release_year": 2016, + "end_year": 2022, + "status": "Ended", + "seasons": 4, + "episodes": 36, + "creators": ["Jonathan Nolan", "Lisa Joy"], + "cast": ["Evan Rachel Wood", "Thandiwe Newton", "Jeffrey Wright", "Ed Harris"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.5, + "description": "Set in a Western-themed park where guests live out their fantasies through robotic hosts that develop consciousness.", + "tags": ["ai", "consciousness", "theme-park", "androids"] + }, + { + "id": "hbo-007", + "title": "The White Lotus", + "platform": "HBO Max", + "genres": ["Comedy", "Drama", "Mystery"], + "release_year": 2021, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 18, + "creators": ["Mike White"], + "cast": ["Murray Bartlett", "Jennifer Coolidge", "Aubrey Plaza", "Theo James"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 7.9, + "description": "The exploits of various employees and guests at an exclusive tropical resort.", + "tags": ["anthology", "satire", "wealth", "vacation"] + }, + { + "id": "disney-001", + "title": "The Mandalorian", + "platform": "Disney+", + "genres": ["Sci-Fi", "Action", "Adventure"], + "release_year": 2019, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 24, + "creators": ["Jon Favreau"], + "cast": ["Pedro Pascal", "Carl Weathers", "Giancarlo Esposito"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 8.6, + "description": "The travels of a lone bounty hunter in the outer reaches of the galaxy, far from the authority of the New Republic.", + "tags": ["star-wars", "space-western", "grogu", "baby-yoda"] + }, + { + "id": "disney-002", + "title": "WandaVision", + "platform": "Disney+", + "genres": ["Sci-Fi", "Drama", "Mystery"], + "release_year": 2021, + "end_year": 2021, + "status": "Ended", + "seasons": 1, + "episodes": 9, + "creators": ["Jac Schaeffer"], + "cast": ["Elizabeth Olsen", "Paul Bettany", "Kathryn Hahn"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 7.9, + "description": "Wanda Maximoff and Vision live idealized suburban lives, but begin to suspect that everything is not as it seems.", + "tags": ["marvel", "mcu", "sitcom", "superhero"] + }, + { + "id": "disney-003", + "title": "Andor", + "platform": "Disney+", + "genres": ["Sci-Fi", "Drama", "Action"], + "release_year": 2022, + "end_year": 2025, + "status": "Ended", + "seasons": 2, + "episodes": 24, + "creators": ["Tony Gilroy"], + "cast": ["Diego Luna", "Genevieve O'Reilly", "Stellan Skarsgård"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 8.4, + "description": "Prequel to Rogue One, exploring a new perspective from the Star Wars galaxy and Cassian Andor's journey to becoming a rebel hero.", + "tags": ["star-wars", "rebellion", "political", "prequel"] + }, + { + "id": "disney-004", + "title": "Loki", + "platform": "Disney+", + "genres": ["Sci-Fi", "Action", "Adventure"], + "release_year": 2021, + "end_year": 2023, + "status": "Ended", + "seasons": 2, + "episodes": 12, + "creators": ["Michael Waldron"], + "cast": ["Tom Hiddleston", "Owen Wilson", "Sophia Di Martino"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 8.2, + "description": "The mercurial villain Loki resumes his role as the God of Mischief in a new series that takes place after the events of Avengers: Endgame.", + "tags": ["marvel", "mcu", "multiverse", "time-travel"] + }, + { + "id": "appletv-001", + "title": "Ted Lasso", + "platform": "Apple TV+", + "genres": ["Comedy", "Drama", "Sport"], + "release_year": 2020, + "end_year": 2023, + "status": "Ended", + "seasons": 3, + "episodes": 34, + "creators": ["Bill Lawrence", "Jason Sudeikis"], + "cast": ["Jason Sudeikis", "Hannah Waddingham", "Brett Goldstein", "Juno Temple"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.8, + "description": "American college football coach Ted Lasso heads to London to manage AFC Richmond, a struggling English Premier League soccer team.", + "tags": ["soccer", "football", "feel-good", "british"] + }, + { + "id": "appletv-002", + "title": "Severance", + "platform": "Apple TV+", + "genres": ["Sci-Fi", "Thriller", "Drama"], + "release_year": 2022, + "end_year": null, + "status": "Ongoing", + "seasons": 2, + "episodes": 19, + "creators": ["Dan Erickson"], + "cast": ["Adam Scott", "Britt Lower", "Zach Cherry", "John Turturro"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "Employees at Lumon Industries undergo a procedure that surgically divides their memories between their work and personal lives.", + "tags": ["workplace", "dystopian", "mystery", "ben-stiller"] + }, + { + "id": "appletv-003", + "title": "Foundation", + "platform": "Apple TV+", + "genres": ["Sci-Fi", "Drama", "Adventure"], + "release_year": 2021, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 28, + "creators": ["David S. Goyer", "Josh Friedman"], + "cast": ["Jared Harris", "Lee Pace", "Lou Llobell"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 7.5, + "description": "A complex saga of humans scattered on planets throughout the galaxy living under the rule of the Galactic Empire.", + "tags": ["space-opera", "asimov", "epic", "empire"] + }, + { + "id": "appletv-004", + "title": "Slow Horses", + "platform": "Apple TV+", + "genres": ["Crime", "Drama", "Thriller"], + "release_year": 2022, + "end_year": null, + "status": "Ongoing", + "seasons": 4, + "episodes": 24, + "creators": ["Will Smith"], + "cast": ["Gary Oldman", "Jack Lowden", "Kristin Scott Thomas"], + "country": "UK", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.3, + "description": "Follows a team of British intelligence agents who serve in a dumping ground department of MI5.", + "tags": ["spy", "british", "mi5", "mick-herron"] + }, + { + "id": "hulu-001", + "title": "The Handmaid's Tale", + "platform": "Hulu", + "genres": ["Drama", "Sci-Fi", "Dystopian"], + "release_year": 2017, + "end_year": 2025, + "status": "Ended", + "seasons": 6, + "episodes": 66, + "creators": ["Bruce Miller"], + "cast": ["Elisabeth Moss", "Joseph Fiennes", "Yvonne Strahovski", "Samira Wiley"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.4, + "description": "Set in a dystopian future, a woman is forced to live as a concubine under a fundamentalist theocratic dictatorship.", + "tags": ["dystopian", "feminist", "atwood", "theocracy"] + }, + { + "id": "hulu-002", + "title": "The Bear", + "platform": "Hulu", + "genres": ["Comedy", "Drama"], + "release_year": 2022, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 28, + "creators": ["Christopher Storer"], + "cast": ["Jeremy Allen White", "Ebon Moss-Bachrach", "Ayo Edebiri"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.6, + "description": "A young chef from the fine dining world returns to Chicago to run his family's sandwich shop after a heartbreaking death.", + "tags": ["cooking", "chicago", "family", "anxiety"] + }, + { + "id": "hulu-003", + "title": "Only Murders in the Building", + "platform": "Hulu", + "genres": ["Comedy", "Crime", "Mystery"], + "release_year": 2021, + "end_year": null, + "status": "Ongoing", + "seasons": 4, + "episodes": 40, + "creators": ["Steve Martin", "John Hoffman"], + "cast": ["Steve Martin", "Martin Short", "Selena Gomez"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.1, + "description": "Three strangers who share an obsession with true crime suddenly find themselves wrapped up in one.", + "tags": ["whodunit", "podcast", "new-york", "comedy-mystery"] + }, + { + "id": "peacock-001", + "title": "Poker Face", + "platform": "Peacock", + "genres": ["Crime", "Comedy", "Mystery"], + "release_year": 2023, + "end_year": null, + "status": "Ongoing", + "seasons": 2, + "episodes": 20, + "creators": ["Rian Johnson"], + "cast": ["Natasha Lyonne"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.1, + "description": "A casino worker with the ability to determine when someone is lying hits the road, solving crimes along the way.", + "tags": ["mystery-of-the-week", "rian-johnson", "columbo", "road-trip"] + }, + { + "id": "paramount-001", + "title": "Yellowstone", + "platform": "Paramount+", + "genres": ["Drama", "Western"], + "release_year": 2018, + "end_year": 2024, + "status": "Ended", + "seasons": 5, + "episodes": 53, + "creators": ["Taylor Sheridan", "John Linson"], + "cast": ["Kevin Costner", "Luke Grimes", "Kelly Reilly", "Wes Bentley"], + "country": "USA", + "language": "English", + "rating": "TV-MA", + "imdb_rating": 8.7, + "description": "A ranching family in Montana faces off against others encroaching on their land.", + "tags": ["western", "ranch", "family-drama", "montana"] + }, + { + "id": "paramount-002", + "title": "Star Trek: Strange New Worlds", + "platform": "Paramount+", + "genres": ["Sci-Fi", "Adventure", "Drama"], + "release_year": 2022, + "end_year": null, + "status": "Ongoing", + "seasons": 3, + "episodes": 30, + "creators": ["Akiva Goldsman", "Alex Kurtzman", "Jenny Lumet"], + "country": "USA", + "language": "English", + "rating": "TV-14", + "imdb_rating": 8.1, + "cast": ["Anson Mount", "Rebecca Romijn", "Ethan Peck"], + "description": "Captain Christopher Pike helms the USS Enterprise as it explores new worlds in the years before Captain Kirk's iconic missions.", + "tags": ["star-trek", "space", "enterprise", "pike"] + } +] From 524b2b6291590aa9eff6116414d770542d964217 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Mon, 18 May 2026 12:22:34 -0400 Subject: [PATCH 17/27] fix(schema): correct atomicity wording and preserve unknown analyzer keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes flagged by the Solr-expert review of PR #131: 1. MultiUpdate IS transactional, not sequential Per the Solr Schema API reference guide and SolrJ's SchemaRequest.MultiUpdate Javadoc, all commands in a single call either succeed or fail together. The previous tool descriptions wrongly told the LLM "commands run in input order; if one fails mid-batch, prior commands remain applied" / "partial application possible on failure" — which would lead an LLM to issue partial- recovery commands that double-apply on retry. Updated both add-fields and add-field-types descriptions and the spec doc to say "Solr's Schema API is transactional — if any command in the batch fails, none are applied." 2. toAnalyzerDefinition silently dropped unknown analyzer keys SolrJ's AnalyzerDefinition only exposes typed setters for charFilters, tokenizer, and filters. A naive objectMapper.convertValue(raw, AnalyzerDefinition.class) silently drops every other top-level analyzer key (class, luceneMatchVersion, positionIncrementGap, ...). This broke the valid single-class analyzer form {"analyzer":{"class":"solr.WhitespaceAnalyzer"}} which the Solr Ref Guide documents for the StandardAnalyzer / WhitespaceAnalyzer / KeywordAnalyzer / per-language analyzer (ArabicAnalyzer etc.) patterns. Rewrote the helper to manually split the map: charFilters, tokenizer, filters go through the typed setters; everything else is preserved via setAttributes(). Added a unit-test regression guard that serializes the captured MultiUpdate's wire body and asserts the analyzer-level class key is present. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../2026-05-17-schema-modification-design.md | 18 +++--- .../solr/mcp/server/schema/SchemaService.java | 60 +++++++++++++++++-- .../mcp/server/schema/SchemaServiceTest.java | 35 +++++++++++ 3 files changed, 100 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-05-17-schema-modification-design.md b/docs/superpowers/specs/2026-05-17-schema-modification-design.md index ff872f8a..4565a993 100644 --- a/docs/superpowers/specs/2026-05-17-schema-modification-design.md +++ b/docs/superpowers/specs/2026-05-17-schema-modification-design.md @@ -105,8 +105,8 @@ reliability and reduce variance across model capabilities. "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. " + - "Commands run in input order; if one fails mid-batch, prior commands remain applied " + - "(use get-schema to inspect on failure)." + "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, @@ -130,7 +130,7 @@ public SchemaUpdateResult addFields( "(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. " + - "Commands run in input order; partial application possible on failure." + "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, @@ -232,12 +232,12 @@ duplicates that work and adds maintenance. `response.getResponse().get("errors")` and throw explicitly. **This API shape needs integration-test verification before relying on it.**) -`MultiUpdate` is **not atomic** — commands process sequentially server-side and a failure -mid-batch leaves prior commands applied. The result type doesn't model this because the -common case is whole-batch success or whole-batch failure on command #1 (the typical -errors — already-exists, unknown-type-reference — fail fast at the first invalid command). -Rare mid-batch failures surface as exceptions; caller can call `get-schema` to see what -landed. +`MultiUpdate` is **transactional** — per Solr's Schema API reference guide and SolrJ's +`SchemaRequest.MultiUpdate` Javadoc, all commands in a single call either succeed or +fail together. Solr returns HTTP 400 with an `errors` array on failure and rolls back +any partially-applied state. SolrJ then throws (verified by the +`addFields_duplicateField_throws` integration test, which passes without needing manual +response-body inspection). ### Native image hints 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 b55e6e04..8b0e7365 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 @@ -276,8 +276,8 @@ public SchemaRepresentation getSchema(String collection) throws Exception { + "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. " - + "Commands run in input order; if one fails mid-batch, prior commands remain applied " - + "(use get-schema to inspect on failure).") + + "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) throws SolrServerException, IOException { @@ -307,7 +307,7 @@ public SchemaUpdateResult addFields(@McpToolParam(description = "Solr collection + "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. " - + "Commands run in input order; partial application possible on failure.") + + "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) throws SolrServerException, IOException { @@ -351,8 +351,60 @@ private FieldTypeDefinition toFieldTypeDefinition(Map input) { return def; } + /** + * Builds an {@link AnalyzerDefinition} from a flat input map matching the Solr + * Schema API analyzer shape. SolrJ's {@code AnalyzerDefinition} only exposes + * typed setters for {@code charFilters}, {@code tokenizer}, and + * {@code filters}; every other analyzer-level key (e.g. + * {@code "class":"solr.WhitespaceAnalyzer"}, {@code "luceneMatchVersion"}, + * {@code "positionIncrementGap"}) must go through {@code setAttributes} or Solr + * never sees them. + * + *

+ * A naive {@code objectMapper.convertValue(raw, AnalyzerDefinition.class)} + * silently drops those unrecognized top-level keys, which breaks the valid + * single-class analyzer form + * {@code {"analyzer":{"class":"solr.WhitespaceAnalyzer"}}}. This helper does + * the split manually so unknown keys are preserved as attributes. + */ private AnalyzerDefinition toAnalyzerDefinition(Object raw) { - return objectMapper.convertValue(raw, AnalyzerDefinition.class); + if (!(raw instanceof Map rawMap)) { + // Defensive: unexpected shape (e.g. analyzer specified as a bare class + // name string). Let Jackson try its default conversion. + return objectMapper.convertValue(raw, AnalyzerDefinition.class); + } + @SuppressWarnings("unchecked") + Map input = (Map) rawMap; + + AnalyzerDefinition def = new AnalyzerDefinition(); + Map attributes = new LinkedHashMap<>(input); + + Object charFilters = attributes.remove("charFilters"); + Object tokenizer = attributes.remove("tokenizer"); + Object filters = attributes.remove("filters"); + + // Anything left (class, luceneMatchVersion, positionIncrementGap, ...) + // is an attribute and must be passed through; SolrJ emits these inside + // the analyzer JSON alongside the typed sub-objects. + if (!attributes.isEmpty()) { + def.setAttributes(attributes); + } + if (charFilters instanceof List charFilterList) { + @SuppressWarnings("unchecked") + List> typed = (List>) charFilterList; + def.setCharFilters(typed); + } + if (tokenizer instanceof Map tokenizerMap) { + @SuppressWarnings("unchecked") + Map typed = (Map) tokenizerMap; + def.setTokenizer(typed); + } + if (filters instanceof List filterList) { + @SuppressWarnings("unchecked") + List> typed = (List>) filterList; + def.setFilters(typed); + } + return def; } private static void requireCollection(String collection) { 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 fd798db8..55cf82b7 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 @@ -286,4 +286,39 @@ void addFieldTypes_solrThrows_propagates() throws Exception { assertThrows(SolrServerException.class, () -> service.addFieldTypes("col", List.of(Map.of("name", "x", "class", "solr.StrField")))); } + + /** + * Regression test for the single-class analyzer form + * {"analyzer":{"class":"solr.WhitespaceAnalyzer"}}. SolrJ's + * {@code AnalyzerDefinition} has no top-level setter for {@code class}, so a + * naive {@code objectMapper.convertValue} silently drops it and Solr gets a + * malformed analyzer. The helper must preserve unknown analyzer-level keys via + * {@code setAttributes}. We verify by serializing the captured MultiUpdate's + * wire body and asserting the analyzer class is present. + */ + @Test + void addFieldTypes_analyzerWithOnlyClassKey_preservesClassInWireFormat() throws Exception { + ObjectMapper realMapper = new ObjectMapper(); + SchemaService service = new SchemaService(solrClient, realMapper); + + List> types = List.of(Map.of("name", "text_whitespace", "class", "solr.TextField", + "analyzer", Map.of("class", "solr.WhitespaceAnalyzer"))); + + when(solrClient.request(any(SolrRequest.class), eq("col"))).thenReturn(new NamedList<>()); + + service.addFieldTypes("col", types); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SolrRequest.class); + verify(solrClient).request(captor.capture(), eq("col")); + SolrRequest req = captor.getValue(); + + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + req.getContentWriter("application/json").write(baos); + String body = baos.toString(java.nio.charset.StandardCharsets.UTF_8); + + assertTrue(body.contains("solr.WhitespaceAnalyzer"), + "Wire body must preserve the analyzer-level 'class' key: " + body); + assertTrue(body.contains("solr.TextField"), + "Wire body must preserve the field-type-level 'class' key: " + body); + } } From 7e176b60af0dbae5f0e12684b06ad533d8fd027b Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Mon, 18 May 2026 13:03:44 -0400 Subject: [PATCH 18/27] refactor(schema): drop noise fields from SchemaUpdateResult MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same drop-noise treatment to SchemaUpdateResult that PR #132 applies to Dtos.java records: - success (boolean) — always true on return; failures throw before reaching the result. - timestamp (Date) — always "now"; sub-second operation, MCP host records call timing already. After this change, SchemaUpdateResult is just (collection, addedNames): both fields carry real information. addedNames echoes the field names back so the LLM can confirm what landed. java.util.Date and com.fasterxml.jackson.annotation.JsonFormat imports drop from the record entirely. Test assertions referencing the dropped fields are removed. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../2026-05-17-schema-modification-design.md | 16 ++++++---------- .../solr/mcp/server/schema/SchemaService.java | 5 ++--- .../mcp/server/schema/SchemaUpdateResult.java | 11 ++++------- .../schema/SchemaServiceIntegrationTest.java | 1 - .../mcp/server/schema/SchemaServiceTest.java | 5 ----- 5 files changed, 12 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/specs/2026-05-17-schema-modification-design.md b/docs/superpowers/specs/2026-05-17-schema-modification-design.md index 4565a993..76d56794 100644 --- a/docs/superpowers/specs/2026-05-17-schema-modification-design.md +++ b/docs/superpowers/specs/2026-05-17-schema-modification-design.md @@ -142,20 +142,16 @@ public SchemaUpdateResult addFieldTypes( ### Result type New record `SchemaUpdateResult` in a new file -`src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java`. Shape matches -`CollectionCreationResult` (project convention): +`src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java`: ```java -public record SchemaUpdateResult( - String collection, - boolean success, - List addedNames, - Date timestamp -) {} +public record SchemaUpdateResult(String collection, List addedNames) {} ``` -`success` is always `true` on return (failures throw). `addedNames` echoes the `name` from -each input definition in input order. No `failures` field — see Failure mode below. +Failures throw and never produce this result, so no `success` flag is needed. +`addedNames` echoes the `name` from each input definition in input order so the +caller can confirm what landed. No `timestamp` — sub-second operation; the MCP +host records call timing already. ### Implementation skeleton 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 8b0e7365..63cfa069 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 @@ -22,7 +22,6 @@ import io.micrometer.observation.annotation.Observed; import java.io.IOException; import java.util.ArrayList; -import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -292,7 +291,7 @@ public SchemaUpdateResult addFields(@McpToolParam(description = "Solr collection } new SchemaRequest.MultiUpdate(updates).process(solrClient, collection); - return new SchemaUpdateResult(collection, true, names, new Date()); + return new SchemaUpdateResult(collection, names); } @PreAuthorize("isAuthenticated()") @@ -322,7 +321,7 @@ public SchemaUpdateResult addFieldTypes(@McpToolParam(description = "Solr collec } new SchemaRequest.MultiUpdate(updates).process(solrClient, collection); - return new SchemaUpdateResult(collection, true, names, new Date()); + return new SchemaUpdateResult(collection, names); } /** diff --git a/src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java b/src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java index 5173c036..b89ebefa 100644 --- a/src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java +++ b/src/main/java/org/apache/solr/mcp/server/schema/SchemaUpdateResult.java @@ -16,22 +16,19 @@ */ package org.apache.solr.mcp.server.schema; -import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; -import java.util.Date; import java.util.List; /** * Result of an additive schema update (add-fields or add-field-types). * *

- * {@code success} is always {@code true} on return — failures throw and never - * produce a result. {@code addedNames} echoes the {@code name} field from each - * input definition in input order, useful for confirming what was sent. + * Failures throw and never produce this result, so no {@code success} flag is + * needed. {@code addedNames} echoes the {@code name} field from each input + * definition in input order so the caller can confirm what landed. */ @JsonIgnoreProperties(ignoreUnknown = true) @JsonInclude(JsonInclude.Include.NON_NULL) -public record SchemaUpdateResult(String collection, boolean success, List addedNames, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date timestamp) { +public record SchemaUpdateResult(String collection, List addedNames) { } diff --git a/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java index 0b39d26a..0303550c 100644 --- a/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceIntegrationTest.java @@ -190,7 +190,6 @@ void addFields_endToEnd_persistsToSchema() throws Exception { SchemaUpdateResult result = schemaService.addFields(TEST_COLLECTION, fields); - assertTrue(result.success()); assertEquals(List.of("addf_title", "addf_platform", "addf_year"), result.addedNames()); SchemaRepresentation schema = schemaService.getSchema(TEST_COLLECTION); 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 55cf82b7..27a206e0 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 @@ -183,10 +183,8 @@ void addFields_happyPath_buildsMultiUpdateAndEchoesNames() throws Exception { SchemaUpdateResult result = schemaService.addFields("col", fields); - assertTrue(result.success()); assertEquals(List.of("title", "platform"), result.addedNames()); assertEquals("col", result.collection()); - assertNotNull(result.timestamp()); ArgumentCaptor captor = ArgumentCaptor.forClass(SolrRequest.class); verify(solrClient).request(captor.capture(), eq("col")); @@ -231,7 +229,6 @@ void addFieldTypes_happyPathWithAnalyzer_buildsCorrectFieldTypeDefinition() thro SchemaUpdateResult result = service.addFieldTypes("col", types); - assertTrue(result.success()); assertEquals(List.of("text_lowercase"), result.addedNames()); ArgumentCaptor captor = ArgumentCaptor.forClass(SolrRequest.class); @@ -256,7 +253,6 @@ void addFieldTypes_separateAnalyzers_buildsCorrectFieldTypeDefinition() throws E SchemaUpdateResult result = service.addFieldTypes("col", types); - assertTrue(result.success()); assertEquals(List.of("text_autocomplete"), result.addedNames()); } @@ -272,7 +268,6 @@ void addFieldTypes_denseVectorField_noAnalyzer() throws Exception { SchemaUpdateResult result = service.addFieldTypes("col", types); - assertTrue(result.success()); assertEquals(List.of("openai_embedding"), result.addedNames()); } From 9245191d70e46c296720bc85d54b5f291682c4af Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Tue, 19 May 2026 11:48:17 -0400 Subject: [PATCH 19/27] feat(mcp): add @McpPrompt endpoints for the four canonical Solr workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds one @McpPrompt method per service so MCP clients can expose slash-command-style entry points to the existing tools: - explore-and-create-collections (CollectionService) - design-schema (SchemaService) - index-data (IndexingService) - search-collection (SearchService) Each prompt returns a String that the framework wraps as a single user-role PromptMessage. Templates point the LLM at the right tools in the right order, embed user-supplied arguments (collection name, question, sample document, etc.) where provided, and cross-reference sibling prompts for next-step workflows. Discovery is automatic via Spring component scanning — no MCP server wiring changes are required, matching how existing @McpTool / @McpResource / @McpComplete methods are registered. Tests: - Unit tests on each *ServiceTest assert prompt bodies mention the expected tool names, embed user inputs (where applicable), and branch correctly on enum-style args (json/csv/xml). - McpClientIntegrationTestBase gains orders 28–32: listPrompts() returns the four names; getPrompt(...) for each prompt returns a non-empty first PromptMessage whose text references the right tools. These run in both stdio and HTTP transports via the existing subclasses. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../server/collection/CollectionService.java | 40 +++++++++ .../mcp/server/indexing/IndexingService.java | 56 +++++++++++++ .../solr/mcp/server/schema/SchemaService.java | 62 ++++++++++++++ .../solr/mcp/server/search/SearchService.java | 56 +++++++++++++ .../server/McpClientIntegrationTestBase.java | 84 +++++++++++++++++++ .../collection/CollectionServiceTest.java | 11 +++ .../server/indexing/IndexingServiceTest.java | 28 +++++++ .../mcp/server/schema/SchemaServiceTest.java | 21 +++++ .../mcp/server/search/SearchServiceTest.java | 14 ++++ 9 files changed, 372 insertions(+) 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 7cbb7a20..67876810 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 @@ -42,6 +42,7 @@ import org.apache.solr.common.util.NamedList; import org.apache.solr.mcp.server.config.SolrConfigurationProperties; 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; @@ -1018,4 +1019,43 @@ public CollectionCreationResult createCollection( return new CollectionCreationResult(name, true, "Collection created successfully", new Date()); } + + @McpPrompt(name = "explore-and-create-collections", title = "Explore Solr collections and create one if needed", description = "Guides the assistant through discovering existing Solr collections, inspecting their health and stats, and creating a new collection when the desired one is missing.") + public String exploreAndCreateCollectionsPrompt() { + return """ + You are working against an Apache Solr cluster through MCP tools. Follow this workflow to + explore the cluster and, if needed, create a new collection. Be incremental — run one tool + at a time and react to the actual output before moving on. + + 1. Discover what already exists. + - Call `list-collections` to get the full set of collection names. + - If the user mentioned a target collection by name, check whether it is in the list. + + 2. Characterize the interesting collections. + - 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. + - Briefly summarize what you learned: which collections exist, which look healthy, + and which look empty or stale. + + 3. Decide whether a new collection is needed. + - If the user's intent matches an existing collection, stop here and surface what you + found. + - Otherwise, pick a clear lowercase name (letters, digits, underscores, hyphens — no + spaces). Default to the SolrCloud-friendly `_default` configset, 1 shard, and + replicationFactor 1 unless the user asked for something else. + + 4. Create the collection. + - Call `create-collection` with `name` and any non-default values for `configSet`, + `numShards`, `replicationFactor`. Report the result (success flag + message). + + 5. Verify. + - Call `list-collections` again to confirm the new collection appears, and + `check-health` on the new name to confirm it responds. + + Next step suggestion: once a collection exists, the user usually wants to design its + schema. The `design-schema` prompt drives that workflow. + """; + } } 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 84349a14..7278228e 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,8 @@ import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.common.SolrInputDocument; import org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator; +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; @@ -456,4 +458,58 @@ public int indexDocuments(String collection, List documents) solrClient.commit(collection); return successCount; } + + @McpPrompt(name = "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) { + String normalizedFormat = (format == null) ? "" : format.trim().toLowerCase(); + String tool = switch (normalizedFormat) { + case "json" -> "index-json-documents"; + case "csv" -> "index-csv-documents"; + case "xml" -> "index-xml-documents"; + default -> "index--documents (format must be one of json/csv/xml)"; + }; + String paramName = switch (normalizedFormat) { + case "json" -> "json"; + case "csv" -> "csv"; + case "xml" -> "xml"; + default -> ""; + }; + String sampleSection = (sample == null || sample.isBlank()) + ? " - No sample was provided. If the user has not pasted the documents yet, ask for them\n (or a representative subset) before indexing." + : " - Sample input:\n\n ```\n " + sample.strip().replace("\n", "\n ") + "\n ```"; + 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: a "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(normalizedFormat.isEmpty() ? "" : normalizedFormat, collection, collection, + sampleSection, tool, collection, 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 63cfa069..b5806945 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 @@ -31,6 +31,8 @@ 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.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; @@ -417,4 +419,64 @@ private static void requireNonEmpty(List list, String name) { throw new IllegalArgumentException(name + " must not be empty"); } } + + @McpPrompt(name = "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 = (sampleDocument == null || sampleDocument.isBlank()) + ? " - No sample document was provided; ask the user for one if the dataset description leaves field types ambiguous." + : " - A sample document was provided. Use it as ground truth for field names and value\n shapes:\n\n ```\n " + + sampleDocument.strip().replace("\n", "\n ") + "\n ```"; + 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 c29ccb6a..984c33ae 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,8 @@ import org.apache.solr.common.SolrDocument; import org.apache.solr.common.SolrDocumentList; import org.apache.solr.common.params.FacetParams; +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; @@ -298,4 +300,58 @@ public SearchResponse search(@McpToolParam(description = "Solr collection to que return new SearchResponse(documents.getNumFound(), documents.getStart(), documents.getMaxScore(), docs, facets); } + + @McpPrompt(name = "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/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java index e260eb3a..692034f1 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,10 @@ import io.modelcontextprotocol.client.McpSyncClient; import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +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.TextContent; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -575,6 +579,86 @@ void getShowsCollectionStats() throws Exception { "Stats should report " + SHOWS_DOC_COUNT + " docs somewhere in the payload: " + text); } + // ===== Prompt workflow (orders 28–32) ===== + // Verifies the four @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(); + + assertTrue(promptNames.contains("explore-and-create-collections"), + "Should expose explore-and-create-collections prompt: " + promptNames); + assertTrue(promptNames.contains("design-schema"), "Should expose design-schema prompt: " + promptNames); + assertTrue(promptNames.contains("index-data"), "Should expose index-data prompt: " + promptNames); + assertTrue(promptNames.contains("search-collection"), "Should expose search-collection prompt: " + promptNames); + } + + @Test + @Order(29) + void getExploreAndCreateCollectionsPromptReturnsGuidance() { + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest("explore-and-create-collections", Map.of())); + + String text = extractFirstMessageText(result); + assertTrue(text.contains("list-collections"), "Prompt body should reference list-collections: " + text); + assertTrue(text.contains("create-collection"), "Prompt body should reference create-collection: " + text); + } + + @Test + @Order(30) + 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(31) + 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(32) + 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); + } + + private static String extractFirstMessageText(GetPromptResult result) { + assertNotNull(result, "GetPromptResult must not be null"); + List messages = result.messages(); + assertNotNull(messages, "messages must not be null"); + assertFalse(messages.isEmpty(), "messages must not be empty"); + Content content = messages.get(0).content(); + assertInstanceOf(TextContent.class, content, "first prompt message content should be TextContent"); + String text = ((TextContent) content).text(); + assertNotNull(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); 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..fb9a8549 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 @@ -866,4 +866,15 @@ void createCollection_solrException_propagates() throws Exception { assertThrows(SolrServerException.class, () -> collectionService.createCollection("fail_core", null, null, null)); } + + @Test + void exploreAndCreateCollectionsPrompt_includesKeyWorkflowSteps() { + String body = collectionService.exploreAndCreateCollectionsPrompt(); + + 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"); + assertTrue(body.contains("create-collection"), "Prompt should reference create-collection tool"); + } } 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..8f8f8655 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,32 @@ private List createMockDocuments(int count) { } return docs; } + + @Test + void indexDataPrompt_jsonPath_referencesIndexJsonDocuments() { + String body = indexingService.indexDataPrompt("library", "json", "[{\"id\":\"1\",\"title\":\"Test\"}]"); + + assertNotNull(body); + 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("{\"id\":\"1\",\"title\":\"Test\"}"), "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"); + } } 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..fe74b40b 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,25 @@ 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("\"title\":\"Widget\""), "Prompt should include the sample document body"); + } } 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"); + } } From e27e8f3263287bc3e3039f1becf017b6ec30d481 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Tue, 19 May 2026 12:11:30 -0400 Subject: [PATCH 20/27] refactor(mcp): split, add view-schema, and tighten prompt code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three review threads on PR #86: 1. Split the previous `explore-and-create-collections` (the literal "AND" in the name was a code smell — two independent intents, different argument shapes) into: - `explore-collections` (no args, read-only) - `setup-collection` (args: name, optional purpose; cross-references `design-schema` for the next step) 2. Add `view-schema` — read-only schema introspection prompt that walks the LLM through summarising fields, types, dynamic fields, copy fields, and the unique key. Distinct from the mutating `design-schema`. 3. Conciseness / language-features pass: - Centralize prompt names in `PromptNames` so prompt bodies that cross-reference siblings cannot drift from the `@McpPrompt(name)` declarations. - Extract the duplicated "optional code-block section" pattern to `util.PromptText.optionalCodeBlock`. - Collapse the two parallel `switch` expressions in `indexDataPrompt` into one switch returning a `record IndexTool(name, paramName)`. Throw `IllegalArgumentException` for unknown formats instead of soft-warning in the prompt text. - Interpolate `DEFAULT_CONFIGSET` / `DEFAULT_NUM_SHARDS` / `DEFAULT_REPLICATION_FACTOR` into `setup-collection` so the prompt text and the tool stay in lockstep. - Replace `list.get(0)` with `list.getFirst()` in `extractFirstMessageText`, `extractText`, and `assertNotError`. Tests updated: - Unit tests reorganised around the new prompt set; added coverage for `view-schema`, `setup-collection` (with and without purpose), and the `IllegalArgumentException` on unknown format. - `McpClientIntegrationTestBase` orders 28–34 list and fetch all six prompts via the real MCP client, referencing `PromptNames` constants rather than literal strings. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../apache/solr/mcp/server/PromptNames.java | 59 ++++++++++++++ .../server/collection/CollectionService.java | 80 ++++++++++++------- .../mcp/server/indexing/IndexingService.java | 60 +++++++------- .../solr/mcp/server/schema/SchemaService.java | 48 +++++++++-- .../solr/mcp/server/search/SearchService.java | 3 +- .../solr/mcp/server/util/PromptText.java | 47 +++++++++++ .../server/McpClientIntegrationTestBase.java | 66 ++++++++++----- .../collection/CollectionServiceTest.java | 28 ++++++- .../server/indexing/IndexingServiceTest.java | 8 ++ .../mcp/server/schema/SchemaServiceTest.java | 16 ++++ 10 files changed, 331 insertions(+), 84 deletions(-) create mode 100644 src/main/java/org/apache/solr/mcp/server/PromptNames.java create mode 100644 src/main/java/org/apache/solr/mcp/server/util/PromptText.java diff --git a/src/main/java/org/apache/solr/mcp/server/PromptNames.java b/src/main/java/org/apache/solr/mcp/server/PromptNames.java new file mode 100644 index 00000000..56befceb --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/PromptNames.java @@ -0,0 +1,59 @@ +/* + * 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; + +/** + * Canonical names of the {@code @McpPrompt} endpoints exposed by this server. + * + *

+ * Centralised so that prompt bodies that reference sibling prompts ("then + * invoke the {@code design-schema} prompt …") and the {@code @McpPrompt(name)} + * declarations share a single source of truth and cannot drift apart. + */ +public final class PromptNames { + + private PromptNames() { + // Constants holder - prevent instantiation + } + + /** Read-only walkthrough: list and characterise existing collections. */ + public static final String EXPLORE_COLLECTIONS = "explore-collections"; + + /** + * Guided setup of a new Solr collection. Uses a distinct verb from the + * {@code create-collection} tool to avoid name collision in MCP clients that + * surface both. + */ + public static final String SETUP_COLLECTION = "setup-collection"; + + /** Read-only schema introspection walkthrough. */ + public static final String VIEW_SCHEMA = "view-schema"; + + /** + * Mutating schema-design walkthrough: add field types and fields for a + * described dataset. + */ + public static final String DESIGN_SCHEMA = "design-schema"; + + /** Walkthrough for indexing documents in JSON / CSV / XML format. */ + public static final String INDEX_DATA = "index-data"; + + /** + * Walkthrough for translating a natural-language question into a Solr search. + */ + public static final String SEARCH_COLLECTION = "search-collection"; +} 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 67876810..300f7dc7 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 @@ -40,7 +40,9 @@ import org.apache.solr.client.solrj.response.SolrPingResponse; import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.util.NamedList; +import org.apache.solr.mcp.server.PromptNames; import org.apache.solr.mcp.server.config.SolrConfigurationProperties; +import org.springaicommunity.mcp.annotation.McpArg; import org.springaicommunity.mcp.annotation.McpComplete; import org.springaicommunity.mcp.annotation.McpPrompt; import org.springaicommunity.mcp.annotation.McpResource; @@ -1020,42 +1022,62 @@ public CollectionCreationResult createCollection( return new CollectionCreationResult(name, true, "Collection created successfully", new Date()); } - @McpPrompt(name = "explore-and-create-collections", title = "Explore Solr collections and create one if needed", description = "Guides the assistant through discovering existing Solr collections, inspecting their health and stats, and creating a new collection when the desired one is missing.") - public String exploreAndCreateCollectionsPrompt() { + @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 working against an Apache Solr cluster through MCP tools. Follow this workflow to - explore the cluster and, if needed, create a new collection. Be incremental — run one tool - at a time and react to the actual output before moving on. + 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. Discover what already exists. + 1. List collections. - Call `list-collections` to get the full set of collection names. - - If the user mentioned a target collection by name, check whether it is in the list. + - If the user mentioned a target collection by name, note whether it appears in the + list. - 2. Characterize the interesting collections. + 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. - - Briefly summarize what you learned: which collections exist, which look healthy, - and which look empty or stale. - - 3. Decide whether a new collection is needed. - - If the user's intent matches an existing collection, stop here and surface what you - found. - - Otherwise, pick a clear lowercase name (letters, digits, underscores, hyphens — no - spaces). Default to the SolrCloud-friendly `_default` configset, 1 shard, and - replicationFactor 1 unless the user asked for something else. - - 4. Create the collection. - - Call `create-collection` with `name` and any non-default values for `configSet`, - `numShards`, `replicationFactor`. Report the result (success flag + message). - - 5. Verify. - - Call `list-collections` again to confirm the new collection appears, and - `check-health` on the new name to confirm it responds. - - Next step suggestion: once a collection exists, the user usually wants to design its - schema. The `design-schema` prompt drives that workflow. - """; + + 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 + `%s` prompt to create one. + """.formatted(PromptNames.SETUP_COLLECTION); + } + + @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 `%s` 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, PromptNames.DESIGN_SCHEMA); } } 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 7278228e..2a8f9237 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 @@ -23,7 +23,9 @@ import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.common.SolrInputDocument; +import org.apache.solr.mcp.server.PromptNames; import org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator; +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; @@ -459,27 +461,31 @@ public int indexDocuments(String collection, List documents) return successCount; } - @McpPrompt(name = "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.") + /** + * 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); + }; + } + + @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) { - String normalizedFormat = (format == null) ? "" : format.trim().toLowerCase(); - String tool = switch (normalizedFormat) { - case "json" -> "index-json-documents"; - case "csv" -> "index-csv-documents"; - case "xml" -> "index-xml-documents"; - default -> "index--documents (format must be one of json/csv/xml)"; - }; - String paramName = switch (normalizedFormat) { - case "json" -> "json"; - case "csv" -> "csv"; - case "xml" -> "xml"; - default -> ""; - }; - String sampleSection = (sample == null || sample.isBlank()) - ? " - No sample was provided. If the user has not pasted the documents yet, ask for them\n (or a representative subset) before indexing." - : " - Sample input:\n\n ```\n " + sample.strip().replace("\n", "\n ") + "\n ```"; + 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. @@ -487,9 +493,9 @@ public String indexDataPrompt( 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. + `%s` 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 @@ -498,18 +504,18 @@ public String indexDataPrompt( - 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: a "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. + - On error, read the message carefully: an "unknown field" error means the schema is + missing a field — go back to step 1 and run `%s`. 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(normalizedFormat.isEmpty() ? "" : normalizedFormat, collection, collection, - sampleSection, tool, collection, paramName, collection); + Next step suggestion: once data is indexed, the `%s` prompt drives searching it. + """.formatted(indexTool.paramName(), collection, collection, PromptNames.DESIGN_SCHEMA, sampleSection, + indexTool.name(), collection, indexTool.paramName(), PromptNames.DESIGN_SCHEMA, collection, + PromptNames.SEARCH_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 b5806945..3a658a01 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,7 @@ 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.PromptNames; import org.springaicommunity.mcp.annotation.McpArg; import org.springaicommunity.mcp.annotation.McpPrompt; import org.springaicommunity.mcp.annotation.McpResource; @@ -420,15 +422,51 @@ private static void requireNonEmpty(List list, String name) { } } - @McpPrompt(name = "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.") + @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 `%s` prompt + drives the additive workflow. + """.formatted(collection, collection, PromptNames.DESIGN_SCHEMA); + } + + @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 = (sampleDocument == null || sampleDocument.isBlank()) - ? " - No sample document was provided; ask the user for one if the dataset description leaves field types ambiguous." - : " - A sample document was provided. Use it as ground truth for field names and value\n shapes:\n\n ```\n " - + sampleDocument.strip().replace("\n", "\n ") + "\n ```"; + 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: 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 984c33ae..d509961d 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,7 @@ 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.PromptNames; import org.springaicommunity.mcp.annotation.McpArg; import org.springaicommunity.mcp.annotation.McpPrompt; import org.springaicommunity.mcp.annotation.McpTool; @@ -301,7 +302,7 @@ public SearchResponse search(@McpToolParam(description = "Solr collection to que return new SearchResponse(documents.getNumFound(), documents.getStart(), documents.getMaxScore(), docs, facets); } - @McpPrompt(name = "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.") + @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) { 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/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java index 692034f1..172c41da 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java @@ -579,8 +579,8 @@ void getShowsCollectionStats() throws Exception { "Stats should report " + SHOWS_DOC_COUNT + " docs somewhere in the payload: " + text); } - // ===== Prompt workflow (orders 28–32) ===== - // Verifies the four @McpPrompt endpoints are discovered, listable, and return + // ===== 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. @@ -592,27 +592,54 @@ void listPromptsReturnsExpectedPrompts() { assertNotNull(promptsResult); List promptNames = promptsResult.prompts().stream().map(p -> p.name()).toList(); - assertTrue(promptNames.contains("explore-and-create-collections"), - "Should expose explore-and-create-collections prompt: " + promptNames); - assertTrue(promptNames.contains("design-schema"), "Should expose design-schema prompt: " + promptNames); - assertTrue(promptNames.contains("index-data"), "Should expose index-data prompt: " + promptNames); - assertTrue(promptNames.contains("search-collection"), "Should expose search-collection prompt: " + promptNames); + for (String expected : List.of(PromptNames.EXPLORE_COLLECTIONS, PromptNames.SETUP_COLLECTION, + PromptNames.VIEW_SCHEMA, PromptNames.DESIGN_SCHEMA, PromptNames.INDEX_DATA, + PromptNames.SEARCH_COLLECTION)) { + assertTrue(promptNames.contains(expected), "Should expose " + expected + " prompt: " + promptNames); + } } @Test @Order(29) - void getExploreAndCreateCollectionsPromptReturnsGuidance() { - GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest("explore-and-create-collections", Map.of())); + void getExploreCollectionsPromptReturnsGuidance() { + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest(PromptNames.EXPLORE_COLLECTIONS, Map.of())); String text = extractFirstMessageText(result); assertTrue(text.contains("list-collections"), "Prompt body should reference list-collections: " + text); - assertTrue(text.contains("create-collection"), "Prompt body should reference create-collection: " + 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(PromptNames.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(PromptNames.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", + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest(PromptNames.DESIGN_SCHEMA, Map.of("collection", SHOWS_COLLECTION, "datasetDescription", "TV shows with title, platform, genres"))); String text = extractFirstMessageText(result); @@ -622,10 +649,10 @@ void getDesignSchemaPromptReturnsGuidance() { } @Test - @Order(31) + @Order(33) void getIndexDataPromptReturnsGuidance() { GetPromptResult result = mcpClient.getPrompt( - new GetPromptRequest("index-data", Map.of("collection", SHOWS_COLLECTION, "format", "json"))); + new GetPromptRequest(PromptNames.INDEX_DATA, Map.of("collection", SHOWS_COLLECTION, "format", "json"))); String text = extractFirstMessageText(result); assertTrue(text.contains("index-json-documents"), @@ -634,9 +661,9 @@ void getIndexDataPromptReturnsGuidance() { } @Test - @Order(32) + @Order(34) void getSearchCollectionPromptReturnsGuidance() { - GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest("search-collection", + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest(PromptNames.SEARCH_COLLECTION, Map.of("collection", SHOWS_COLLECTION, "question", "What sci-fi shows are on Netflix?"))); String text = extractFirstMessageText(result); @@ -651,7 +678,7 @@ private static String extractFirstMessageText(GetPromptResult result) { List messages = result.messages(); assertNotNull(messages, "messages must not be null"); assertFalse(messages.isEmpty(), "messages must not be empty"); - Content content = messages.get(0).content(); + Content content = messages.getFirst().content(); assertInstanceOf(TextContent.class, content, "first prompt message content should be TextContent"); String text = ((TextContent) content).text(); assertNotNull(text); @@ -669,15 +696,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/collection/CollectionServiceTest.java b/src/test/java/org/apache/solr/mcp/server/collection/CollectionServiceTest.java index fb9a8549..82b113de 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 @@ -868,13 +868,35 @@ void createCollection_solrException_propagates() throws Exception { } @Test - void exploreAndCreateCollectionsPrompt_includesKeyWorkflowSteps() { - String body = collectionService.exploreAndCreateCollectionsPrompt(); + 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"); - assertTrue(body.contains("create-collection"), "Prompt should reference create-collection 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"); } } 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 8f8f8655..075a68fc 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 @@ -351,4 +351,12 @@ void indexDataPrompt_xmlPath_referencesIndexXmlDocuments() { 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/schema/SchemaServiceTest.java b/src/test/java/org/apache/solr/mcp/server/schema/SchemaServiceTest.java index fe74b40b..c053c294 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 @@ -337,4 +337,20 @@ void designSchemaPrompt_embedsSampleDocumentWhenProvided() { assertTrue(body.contains("\"title\":\"Widget\""), "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"); + } } From 12dafe4a116a36ccbe4d587d24dc1fff64755f95 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Tue, 19 May 2026 12:42:38 -0400 Subject: [PATCH 21/27] refactor(mcp): inline prompt names, text blocks for JSON in tests, drop redundant null checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three follow-up review points: 1. Drop PromptNames central class. Six string constants used in ~4 cross-references and 6 annotations is not enough complexity to justify a separate file + import everywhere. The prompt name IS the public MCP contract; if it changes, the change is intentional and grep finds the references. Inlining literals reads better at the call site. 2. Use Java text blocks for the JSON sample strings in the new prompt unit tests (IndexingServiceTest, SchemaServiceTest) so the JSON is readable instead of a sea of escaped quotes. Assert the embedded sample by direct equality with the source text block, not against a re-escaped literal. 3. Remove the redundant `assertNotNull(messages)` and `assertNotNull(text)` from `extractFirstMessageText`. These run inside the `@NullMarked` package `org.apache.solr.mcp.server`, where the McpSchema record accessor `result.messages()` and `TextContent.text()` are non-null by JSpecify semantics — the asserts were dead defensive code. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../apache/solr/mcp/server/PromptNames.java | 59 ------------------- .../server/collection/CollectionService.java | 15 +++-- .../mcp/server/indexing/IndexingService.java | 21 ++++--- .../solr/mcp/server/schema/SchemaService.java | 11 ++-- .../solr/mcp/server/search/SearchService.java | 3 +- .../server/McpClientIntegrationTestBase.java | 20 +++---- .../server/indexing/IndexingServiceTest.java | 8 ++- .../mcp/server/schema/SchemaServiceTest.java | 6 +- 8 files changed, 40 insertions(+), 103 deletions(-) delete mode 100644 src/main/java/org/apache/solr/mcp/server/PromptNames.java diff --git a/src/main/java/org/apache/solr/mcp/server/PromptNames.java b/src/main/java/org/apache/solr/mcp/server/PromptNames.java deleted file mode 100644 index 56befceb..00000000 --- a/src/main/java/org/apache/solr/mcp/server/PromptNames.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * 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; - -/** - * Canonical names of the {@code @McpPrompt} endpoints exposed by this server. - * - *

- * Centralised so that prompt bodies that reference sibling prompts ("then - * invoke the {@code design-schema} prompt …") and the {@code @McpPrompt(name)} - * declarations share a single source of truth and cannot drift apart. - */ -public final class PromptNames { - - private PromptNames() { - // Constants holder - prevent instantiation - } - - /** Read-only walkthrough: list and characterise existing collections. */ - public static final String EXPLORE_COLLECTIONS = "explore-collections"; - - /** - * Guided setup of a new Solr collection. Uses a distinct verb from the - * {@code create-collection} tool to avoid name collision in MCP clients that - * surface both. - */ - public static final String SETUP_COLLECTION = "setup-collection"; - - /** Read-only schema introspection walkthrough. */ - public static final String VIEW_SCHEMA = "view-schema"; - - /** - * Mutating schema-design walkthrough: add field types and fields for a - * described dataset. - */ - public static final String DESIGN_SCHEMA = "design-schema"; - - /** Walkthrough for indexing documents in JSON / CSV / XML format. */ - public static final String INDEX_DATA = "index-data"; - - /** - * Walkthrough for translating a natural-language question into a Solr search. - */ - public static final String SEARCH_COLLECTION = "search-collection"; -} 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 300f7dc7..837e6876 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 @@ -40,7 +40,6 @@ import org.apache.solr.client.solrj.response.SolrPingResponse; import org.apache.solr.common.params.ModifiableSolrParams; import org.apache.solr.common.util.NamedList; -import org.apache.solr.mcp.server.PromptNames; import org.apache.solr.mcp.server.config.SolrConfigurationProperties; import org.springaicommunity.mcp.annotation.McpArg; import org.springaicommunity.mcp.annotation.McpComplete; @@ -1022,7 +1021,7 @@ public CollectionCreationResult createCollection( return new CollectionCreationResult(name, true, "Collection created successfully", new Date()); } - @McpPrompt(name = PromptNames.EXPLORE_COLLECTIONS, title = "Explore Solr collections", description = "Read-only walkthrough: list collections and characterise each by stats and health.") + @McpPrompt(name = "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, @@ -1044,11 +1043,11 @@ public String exploreCollectionsPrompt() { - 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 - `%s` prompt to create one. - """.formatted(PromptNames.SETUP_COLLECTION); + `setup-collection` prompt to create one. + """; } - @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.") + @McpPrompt(name = "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) { @@ -1075,9 +1074,9 @@ public String setupCollectionPrompt( - 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 `%s` prompt to design fields for the - dataset the user wants to index. + 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, PromptNames.DESIGN_SCHEMA); + 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 2a8f9237..d83ca42b 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 @@ -23,7 +23,6 @@ import org.apache.solr.client.solrj.SolrClient; import org.apache.solr.client.solrj.SolrServerException; import org.apache.solr.common.SolrInputDocument; -import org.apache.solr.mcp.server.PromptNames; import org.apache.solr.mcp.server.indexing.documentcreator.IndexingDocumentCreator; import org.apache.solr.mcp.server.util.PromptText; import org.springaicommunity.mcp.annotation.McpArg; @@ -478,7 +477,7 @@ private static IndexTool resolveIndexTool(String format) { }; } - @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.") + @McpPrompt(name = "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, @@ -493,9 +492,9 @@ public String indexDataPrompt( 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 - `%s` 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. + `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 @@ -505,17 +504,17 @@ public String indexDataPrompt( - 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 `%s`. A parse error means the input - format does not match the chosen tool — fix the payload and retry. + 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 `%s` prompt drives searching it. - """.formatted(indexTool.paramName(), collection, collection, PromptNames.DESIGN_SCHEMA, sampleSection, - indexTool.name(), collection, indexTool.paramName(), PromptNames.DESIGN_SCHEMA, collection, - PromptNames.SEARCH_COLLECTION); + 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 3a658a01..3da47a07 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 @@ -32,7 +32,6 @@ 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.PromptNames; import org.springaicommunity.mcp.annotation.McpArg; import org.springaicommunity.mcp.annotation.McpPrompt; import org.springaicommunity.mcp.annotation.McpResource; @@ -422,7 +421,7 @@ private static void requireNonEmpty(List list, String name) { } } - @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.") + @McpPrompt(name = "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 """ @@ -454,12 +453,12 @@ public String viewSchemaPrompt( - 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 `%s` prompt - drives the additive workflow. - """.formatted(collection, collection, PromptNames.DESIGN_SCHEMA); + Next step suggestion: if the schema is missing fields the user needs, the + `design-schema` prompt drives the additive workflow. + """.formatted(collection, collection); } - @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.") + @McpPrompt(name = "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, 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 d509961d..984c33ae 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,7 +30,6 @@ 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.PromptNames; import org.springaicommunity.mcp.annotation.McpArg; import org.springaicommunity.mcp.annotation.McpPrompt; import org.springaicommunity.mcp.annotation.McpTool; @@ -302,7 +301,7 @@ public SearchResponse search(@McpToolParam(description = "Solr collection to que return new SearchResponse(documents.getNumFound(), documents.getStart(), documents.getMaxScore(), docs, facets); } - @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.") + @McpPrompt(name = "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) { 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 172c41da..89118dcb 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java @@ -592,9 +592,8 @@ void listPromptsReturnsExpectedPrompts() { assertNotNull(promptsResult); List promptNames = promptsResult.prompts().stream().map(p -> p.name()).toList(); - for (String expected : List.of(PromptNames.EXPLORE_COLLECTIONS, PromptNames.SETUP_COLLECTION, - PromptNames.VIEW_SCHEMA, PromptNames.DESIGN_SCHEMA, PromptNames.INDEX_DATA, - PromptNames.SEARCH_COLLECTION)) { + 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); } } @@ -602,7 +601,7 @@ void listPromptsReturnsExpectedPrompts() { @Test @Order(29) void getExploreCollectionsPromptReturnsGuidance() { - GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest(PromptNames.EXPLORE_COLLECTIONS, Map.of())); + 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); @@ -614,7 +613,7 @@ void getExploreCollectionsPromptReturnsGuidance() { @Test @Order(30) void getSetupCollectionPromptReturnsGuidance() { - GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest(PromptNames.SETUP_COLLECTION, + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest("setup-collection", Map.of("name", "scratch_collection", "purpose", "Testing setup-collection prompt"))); String text = extractFirstMessageText(result); @@ -628,7 +627,7 @@ void getSetupCollectionPromptReturnsGuidance() { @Order(31) void getViewSchemaPromptReturnsGuidance() { GetPromptResult result = mcpClient - .getPrompt(new GetPromptRequest(PromptNames.VIEW_SCHEMA, Map.of("collection", SHOWS_COLLECTION))); + .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); @@ -639,7 +638,7 @@ void getViewSchemaPromptReturnsGuidance() { @Test @Order(32) void getDesignSchemaPromptReturnsGuidance() { - GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest(PromptNames.DESIGN_SCHEMA, + GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest("design-schema", Map.of("collection", SHOWS_COLLECTION, "datasetDescription", "TV shows with title, platform, genres"))); String text = extractFirstMessageText(result); @@ -652,7 +651,7 @@ void getDesignSchemaPromptReturnsGuidance() { @Order(33) void getIndexDataPromptReturnsGuidance() { GetPromptResult result = mcpClient.getPrompt( - new GetPromptRequest(PromptNames.INDEX_DATA, Map.of("collection", SHOWS_COLLECTION, "format", "json"))); + new GetPromptRequest("index-data", Map.of("collection", SHOWS_COLLECTION, "format", "json"))); String text = extractFirstMessageText(result); assertTrue(text.contains("index-json-documents"), @@ -663,7 +662,7 @@ void getIndexDataPromptReturnsGuidance() { @Test @Order(34) void getSearchCollectionPromptReturnsGuidance() { - GetPromptResult result = mcpClient.getPrompt(new GetPromptRequest(PromptNames.SEARCH_COLLECTION, + 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); @@ -674,14 +673,11 @@ void getSearchCollectionPromptReturnsGuidance() { } private static String extractFirstMessageText(GetPromptResult result) { - assertNotNull(result, "GetPromptResult must not be null"); List messages = result.messages(); - assertNotNull(messages, "messages must not be null"); 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(); - assertNotNull(text); assertFalse(text.isBlank(), "prompt message text should not be blank"); return text; } 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 075a68fc..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 @@ -326,15 +326,17 @@ private List createMockDocuments(int count) { @Test void indexDataPrompt_jsonPath_referencesIndexJsonDocuments() { - String body = indexingService.indexDataPrompt("library", "json", "[{\"id\":\"1\",\"title\":\"Test\"}]"); + String sample = """ + [{"id":"1","title":"Test"}]"""; + + String body = indexingService.indexDataPrompt("library", "json", sample); - assertNotNull(body); 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("{\"id\":\"1\",\"title\":\"Test\"}"), "Prompt should embed the sample payload"); + assertTrue(body.contains(sample), "Prompt should embed the sample payload"); } @Test 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 c053c294..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 @@ -332,10 +332,12 @@ void designSchemaPrompt_includesKeyWorkflowSteps() { @Test void designSchemaPrompt_embedsSampleDocumentWhenProvided() { - String sample = "{\"id\":\"sku-1\",\"title\":\"Widget\",\"price\":9.99}"; + String sample = """ + {"id":"sku-1","title":"Widget","price":9.99}"""; + String body = schemaService.designSchemaPrompt("products", "Catalog", sample); - assertTrue(body.contains("\"title\":\"Widget\""), "Prompt should include the sample document body"); + assertTrue(body.contains(sample), "Prompt should include the sample document body"); } @Test From 6eee8055391f854f9be37651c4b6a873300dab2a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 22:13:51 +0000 Subject: [PATCH 22/27] feat(completion): filter collection completions by user-typed prefix The existing @McpComplete handler for solr://{collection}/schema returned every collection regardless of what the user had typed, which defeats the point of an autocomplete suggestion for clients with a resource-template picker. Accept the CompleteRequest.CompleteArgument so the handler can filter case-insensitively by prefix, sort for stable ordering, and cap the response at 100 suggestions to avoid pathological payloads on large clusters. Adds unit tests for prefix matching, case insensitivity, the wrong argument-name guard, null argument, the result cap, and the listCollections failure path; an annotation-binding registration test; and an end-to-end integration test that calls completeCompletion through a real McpSyncClient and asserts the live collection appears in the results. --- .../server/collection/CollectionService.java | 38 ++++++-- .../solr/mcp/server/schema/SchemaService.java | 1 + .../server/McpClientIntegrationTestBase.java | 35 +++++++ .../mcp/server/McpToolRegistrationTest.java | 47 ++++++++++ .../collection/CollectionServiceTest.java | 94 +++++++++++++++++++ 5 files changed, 207 insertions(+), 8 deletions(-) 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 7cbb7a20..dcf175df 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; @@ -282,24 +284,44 @@ public CollectionService(SolrClient solrClient, ObjectMapper objectMapper) { * * @return JSON string containing the list of collections */ + @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(); + } } /** 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 63cfa069..0cfd8dd3 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 @@ -171,6 +171,7 @@ public SchemaService(SolrClient solrClient, ObjectMapper objectMapper) { * the name of the collection to retrieve schema for * @return JSON string containing the schema representation */ + @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 { 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 e260eb3a..7ca6ac80 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,9 @@ 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.ResourceReference; import io.modelcontextprotocol.spec.McpSchema.TextContent; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -575,6 +578,38 @@ void getShowsCollectionStats() throws Exception { "Stats should report " + SHOWS_DOC_COUNT + " docs somewhere in the payload: " + text); } + @Test + @Order(28) + 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(29) + 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()); + } + private static String loadClasspathResource(String resourcePath) throws Exception { try (InputStream in = McpClientIntegrationTestBase.class.getResourceAsStream(resourcePath)) { Objects.requireNonNull(in, "Classpath resource not found: " + resourcePath); 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..705b437a 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,23 @@ 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.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 +186,47 @@ void testMcpToolParametersFollowConventions() throws NoSuchMethodException { } } + @Test + void testCollectionCompletionBindsToSchemaResourceTemplate() { + List completeMethods = Arrays.stream(CollectionService.class.getDeclaredMethods()) + .filter(m -> m.isAnnotationPresent(McpComplete.class)).toList(); + + assertEquals(1, completeMethods.size(), "CollectionService should expose exactly one @McpComplete handler"); + + McpComplete annotation = completeMethods.get(0).getAnnotation(McpComplete.class); + assertEquals("solr://{collection}/schema", annotation.uri(), + "Completion should target the schema resource URI template"); + assertTrue(annotation.prompt().isEmpty(), "uri and prompt are mutually exclusive on @McpComplete"); + } + + /** + * 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..53947a98 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,96 @@ void createCollection_solrException_propagates() throws Exception { assertThrows(SolrServerException.class, () -> collectionService.createCollection("fail_core", null, null, null)); } + + // 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()); + } } From 1136670a61a14fb9ecfce52082563be44e05797a Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 22 May 2026 15:18:40 -0400 Subject: [PATCH 23/27] feat(security): require auth on every @McpPrompt method Apply @PreAuthorize("isAuthenticated()") to all six @McpPrompt endpoints so they match the existing pattern on @McpTool, @McpResource, and @McpComplete. Spring's proxy-based security is bypassed by self-invocation, so each entry point must carry its own annotation. Without this, the sweeping invariant in McpToolRegistrationTest (introduced alongside the completion prefix-filter work) fails when the prompt and completion changes land together. Signed-off-by: adityamparikh --- .../apache/solr/mcp/server/collection/CollectionService.java | 4 ++++ .../org/apache/solr/mcp/server/indexing/IndexingService.java | 2 ++ .../java/org/apache/solr/mcp/server/schema/SchemaService.java | 4 ++++ .../java/org/apache/solr/mcp/server/search/SearchService.java | 2 ++ 4 files changed, 12 insertions(+) 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 837e6876..ca6ab218 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 @@ -1021,6 +1021,8 @@ public CollectionCreationResult createCollection( return new CollectionCreationResult(name, true, "Collection created successfully", new Date()); } + @PreAuthorize("isAuthenticated()") + @McpPrompt(name = "explore-collections", title = "Explore Solr collections", description = "Read-only walkthrough: list collections and characterise each by stats and health.") public String exploreCollectionsPrompt() { return """ @@ -1047,6 +1049,8 @@ public String exploreCollectionsPrompt() { """; } + @PreAuthorize("isAuthenticated()") + @McpPrompt(name = "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, 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 d83ca42b..32c0c27b 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 @@ -477,6 +477,8 @@ private static IndexTool resolveIndexTool(String format) { }; } + @PreAuthorize("isAuthenticated()") + @McpPrompt(name = "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, 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 3da47a07..935e21a1 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 @@ -421,6 +421,8 @@ private static void requireNonEmpty(List list, String name) { } } + @PreAuthorize("isAuthenticated()") + @McpPrompt(name = "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) { @@ -458,6 +460,8 @@ public String viewSchemaPrompt( """.formatted(collection, collection); } + @PreAuthorize("isAuthenticated()") + @McpPrompt(name = "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, 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 984c33ae..94817d93 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 @@ -301,6 +301,8 @@ public SearchResponse search(@McpToolParam(description = "Solr collection to que return new SearchResponse(documents.getNumFound(), documents.getStart(), documents.getMaxScore(), docs, facets); } + @PreAuthorize("isAuthenticated()") + @McpPrompt(name = "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, From 9a2ca1a0b03b6b38c22edbac795dc016f63f62ac Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Fri, 22 May 2026 09:33:08 -0400 Subject: [PATCH 24/27] feat(mcp): annotate tools with behavior hints (readOnly/destructive/idempotent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP clients use these annotations to decide whether to prompt for user approval before invoking a tool. Without them, every call has to be treated as worst-case destructive, which produces consent fatigue and discourages clients from auto-allowing safe reads. Hints applied: - search, list-collections, get-collection-stats, check-health, get-schema → readOnlyHint=true - create-collection → destructiveHint=false (additive provisioning) - index-{json,csv,xml}-documents → idempotentHint=true (Solr overwrites by uniqueKey, so re-posting the same payload leaves the index in the same end state) NSA's "Model Context Protocol: Security Design Considerations" (U/OO/6030316-26, May 2026) flags poor approval workflows as a top risk; exposing these hints is the server-side enabler clients need to build sensible approval UX. Test: extends McpClientIntegrationTestBase to assert each hint flows through to listTools output, so both HTTP and stdio transports verify the wire-level annotations. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../server/collection/CollectionService.java | 8 ++-- .../mcp/server/indexing/IndexingService.java | 6 +-- .../solr/mcp/server/schema/SchemaService.java | 2 +- .../solr/mcp/server/search/SearchService.java | 2 +- .../server/McpClientIntegrationTestBase.java | 44 +++++++++++++++++++ 5 files changed, 53 insertions(+), 9 deletions(-) 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 fd01de04..0785b802 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 @@ -355,7 +355,7 @@ public List completeCollection(CompleteRequest.CompleteArgument argument * @see CollectionAdminRequest.List */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "list-collections", 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); @@ -426,7 +426,7 @@ public List listCollections() throws SolrServerException, IOException { * @see #extractCollectionName(String) */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "get-collection-stats", 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 { @@ -968,7 +968,7 @@ private boolean validateCollectionExists(String collection) throws SolrServerExc * @see SolrPingResponse */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "check-health", 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 { @@ -1020,7 +1020,7 @@ public SolrHealthStatus checkHealth(@McpToolParam(description = "Solr collection * if there are I/O errors during communication */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "create-collection", description = "Create a new Solr collection. " + @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, 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 32c0c27b..57458de2 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 @@ -195,7 +195,7 @@ public IndexingService(SolrClient solrClient, IndexingDocumentCreator indexingDo * @see #indexDocuments(String, List) */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "index-json-documents", 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 { @@ -263,7 +263,7 @@ public String indexJsonDocuments(@McpToolParam(description = "Solr collection to * @see #indexDocuments(String, List) */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "index-csv-documents", 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 { @@ -355,7 +355,7 @@ public String indexCsvDocuments(@McpToolParam(description = "Solr collection to * @see #indexDocuments(String, List) */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "index-xml-documents", 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 { 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 86dec741..fd9c00f9 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 @@ -264,7 +264,7 @@ public String getSchemaResource(String collection) { * @see org.apache.solr.client.solrj.response.schema.SchemaResponse */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "get-schema", 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(); 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 94817d93..5f6bdd92 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 @@ -217,7 +217,7 @@ private static Map> getFacets(QueryResponse queryRespo * If there's an I/O error */ @PreAuthorize("isAuthenticated()") - @McpTool(name = "search", description = """ + @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. 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 ba80f5b9..cc4e567a 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java @@ -31,11 +31,14 @@ import io.modelcontextprotocol.spec.McpSchema.PromptMessage; import io.modelcontextprotocol.spec.McpSchema.ResourceReference; import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.Tool; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.MethodOrderer; @@ -109,6 +112,47 @@ void listToolsReturnsExpectedTools() { assertTrue(toolNames.contains("add-field-types"), "Should have add-field-types tool"); } + @Test + @Order(2) + void toolsExposeBehaviorHints() { + Map tools = mcpClient.listTools().tools().stream() + .collect(Collectors.toMap(Tool::name, Function.identity())); + + // Read-only tools — clients can call without approval prompts. + assertReadOnly(tools, "search"); + assertReadOnly(tools, "list-collections"); + assertReadOnly(tools, "get-collection-stats"); + assertReadOnly(tools, "check-health"); + assertReadOnly(tools, "get-schema"); + + // create-collection: additive write (provisions a new collection, not + // idempotent because a second call with the same name errors). + assertHint(tools, "create-collection", /* readOnly */ false, /* destructive */ false, /* idempotent */ false); + + // Indexing: destructive (Solr overwrites by uniqueKey) but idempotent — + // posting the same JSON/CSV/XML twice leaves the index in the same state. + assertHint(tools, "index-json-documents", false, true, true); + assertHint(tools, "index-csv-documents", false, true, true); + assertHint(tools, "index-xml-documents", false, true, true); + } + + private static void assertReadOnly(Map tools, String name) { + Tool tool = tools.get(name); + assertNotNull(tool, name + " tool should be present"); + assertNotNull(tool.annotations(), name + " should expose hints"); + assertEquals(Boolean.TRUE, tool.annotations().readOnlyHint(), name + " should be readOnly"); + } + + private static void assertHint(Map tools, String name, boolean readOnly, boolean destructive, + boolean idempotent) { + Tool tool = tools.get(name); + assertNotNull(tool, name + " tool should be present"); + assertNotNull(tool.annotations(), name + " should expose hints"); + assertEquals(readOnly, tool.annotations().readOnlyHint(), name + " readOnlyHint mismatch"); + assertEquals(destructive, tool.annotations().destructiveHint(), name + " destructiveHint mismatch"); + assertEquals(idempotent, tool.annotations().idempotentHint(), name + " idempotentHint mismatch"); + } + @Test @Order(3) void createCollection() { From be056bb043a211293bdb779db1776aad825bf1b3 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Mon, 25 May 2026 18:54:19 -0400 Subject: [PATCH 25/27] feat(completion): handle prompt-arg completion for collection-taking prompts The existing @McpComplete bound only to the schema resource template, so MCP Inspector's completion/complete with a ref/prompt reference (e.g. search-collection) returned -32602 AsyncCompletionSpecification not found. Add @McpComplete(prompt = ...) handlers for the four prompts that take a 'collection' argument (search-collection, index-data, view-schema, design-schema), each delegating to the existing completeCollection logic. Route both @McpPrompt(name=...) and @McpComplete(prompt=...) through a new PromptNames constants class so a typo or rename surfaces as a compile error instead of a silent runtime failure. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../server/collection/CollectionService.java | 52 ++++++++++++++++- .../mcp/server/indexing/IndexingService.java | 3 +- .../solr/mcp/server/schema/SchemaService.java | 5 +- .../solr/mcp/server/search/SearchService.java | 3 +- .../solr/mcp/server/util/PromptNames.java | 58 +++++++++++++++++++ .../server/McpClientIntegrationTestBase.java | 41 +++++++++++++ .../mcp/server/McpToolRegistrationTest.java | 42 ++++++++++---- 7 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 src/main/java/org/apache/solr/mcp/server/util/PromptNames.java 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 0785b802..40daf0ed 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 @@ -43,6 +43,7 @@ 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; @@ -326,6 +327,53 @@ public List completeCollection(CompleteRequest.CompleteArgument argument } } + /** + * 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); + } + /** * Lists all available Solr collections in the SolrCloud cluster. * @@ -1045,7 +1093,7 @@ public CollectionCreationResult createCollection( @PreAuthorize("isAuthenticated()") - @McpPrompt(name = "explore-collections", title = "Explore Solr collections", description = "Read-only walkthrough: list collections and characterise each by stats and health.") + @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, @@ -1073,7 +1121,7 @@ public String exploreCollectionsPrompt() { @PreAuthorize("isAuthenticated()") - @McpPrompt(name = "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.") + @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) { 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 57458de2..15d1d053 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,7 @@ 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; @@ -479,7 +480,7 @@ private static IndexTool resolveIndexTool(String format) { @PreAuthorize("isAuthenticated()") - @McpPrompt(name = "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.") + @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, 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 fd9c00f9..467bc527 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 @@ -32,6 +32,7 @@ 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; @@ -424,7 +425,7 @@ private static void requireNonEmpty(List list, String name) { @PreAuthorize("isAuthenticated()") - @McpPrompt(name = "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.") + @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 """ @@ -463,7 +464,7 @@ public String viewSchemaPrompt( @PreAuthorize("isAuthenticated()") - @McpPrompt(name = "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.") + @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, 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 5f6bdd92..18e9f002 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,7 @@ 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; @@ -303,7 +304,7 @@ public SearchResponse search(@McpToolParam(description = "Solr collection to que @PreAuthorize("isAuthenticated()") - @McpPrompt(name = "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.") + @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) { 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/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java index cc4e567a..2f617ec1 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java +++ b/src/test/java/org/apache/solr/mcp/server/McpClientIntegrationTestBase.java @@ -29,6 +29,7 @@ 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; @@ -753,6 +754,46 @@ void completeCollection_NoMatchesReturnsEmptyValues() { "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"); 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 705b437a..3813675f 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java @@ -28,6 +28,7 @@ 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; @@ -187,16 +188,37 @@ void testMcpToolParametersFollowConventions() throws NoSuchMethodException { } @Test - void testCollectionCompletionBindsToSchemaResourceTemplate() { - List completeMethods = Arrays.stream(CollectionService.class.getDeclaredMethods()) - .filter(m -> m.isAnnotationPresent(McpComplete.class)).toList(); - - assertEquals(1, completeMethods.size(), "CollectionService should expose exactly one @McpComplete handler"); - - McpComplete annotation = completeMethods.get(0).getAnnotation(McpComplete.class); - assertEquals("solr://{collection}/schema", annotation.uri(), - "Completion should target the schema resource URI template"); - assertTrue(annotation.prompt().isEmpty(), "uri and prompt are mutually exclusive on @McpComplete"); + 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"); + } } /** From 31feb4ea4c3a9fd03f8cb325c699a24b070fccd3 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Mon, 25 May 2026 19:36:07 -0400 Subject: [PATCH 26/27] style(mcp): one-arg-per-line for multi-arg annotations; fix schema-write hints Configure Spotless's Eclipse JDT formatter to wrap multi-argument annotations one-arg-per-line via M_ONE_PER_LINE_SPLIT (alignment value 48). Single-arg annotations stay on one line because M_FORCE is intentionally omitted. This makes @McpTool / @McpPrompt / @McpResource / @McpArg / @McpToolParam behavior hints and descriptive copy readable at a glance instead of being crammed onto a 120-column line. Side effect: a handful of @SpringBootTest annotations in tests rewrap the same way, which is consistent. The search() @McpTool's text block masks the formatter's column counter, so that single declaration is wrapped behind '// @formatter:off' to match the others. Also corrects two missing behavior hints surfaced while auditing: - add-fields and add-field-types are additive-only schema operations. Per the MCP spec the default destructiveHint is true, so both tools were advertising 'may perform destructive updates' when they only ever add. Both now declare destructiveHint=false. Verified against the MCP tool annotation defaults: readOnlyHint = false (default) destructiveHint = true (default; meaningful only when readOnly=false) idempotentHint = false (default; meaningful only when readOnly=false) Final per-tool audit: list-collections, get-collection-stats, check-health, search, get-schema - readOnlyHint=true create-collection - destructiveHint=false (additive; second call fails) add-fields, add-field-types - destructiveHint=false (additive only) index-{json,csv,xml}-documents - idempotentHint=true (Solr upserts on id) Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- build.gradle.kts | 5 +- .../eclipse-java-formatter.properties | 16 +++ .../server/collection/CollectionService.java | 57 ++++++++--- .../mcp/server/indexing/IndexingService.java | 35 +++++-- .../solr/mcp/server/schema/SchemaService.java | 99 ++++++++++++------- .../solr/mcp/server/search/SearchService.java | 26 ++++- .../mcp/server/McpClientIntegrationTest.java | 5 +- .../observability/DistributedTracingTest.java | 25 ++--- .../OtlpExportIntegrationTest.java | 8 +- 9 files changed, 199 insertions(+), 77 deletions(-) create mode 100644 config/spotless/eclipse-java-formatter.properties 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 40daf0ed..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 @@ -288,7 +288,11 @@ public CollectionService(SolrClient solrClient, ObjectMapper objectMapper) { * @return JSON string containing the list of collections */ @PreAuthorize("isAuthenticated()") - @McpResource(uri = "solr://collections", name = "solr-collections", description = "List of all Solr collections available in the cluster", mimeType = "application/json") + @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()); } @@ -403,7 +407,10 @@ public List completeDesignSchemaPromptArg(CompleteRequest.CompleteArgume * @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); @@ -474,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 { @@ -1016,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 { @@ -1068,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()) { @@ -1093,7 +1113,10 @@ public CollectionCreationResult createCollection( @PreAuthorize("isAuthenticated()") - @McpPrompt(name = PromptNames.EXPLORE_COLLECTIONS, title = "Explore Solr collections", description = "Read-only walkthrough: list collections and characterise each by stats and health.") + @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, @@ -1121,10 +1144,18 @@ public String exploreCollectionsPrompt() { @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) { + @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 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 15d1d053..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 @@ -196,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 { @@ -264,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 { @@ -356,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 { @@ -480,11 +489,23 @@ private static IndexTool resolveIndexTool(String 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.") + @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) { + @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."); 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 467bc527..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 @@ -176,7 +176,11 @@ public SchemaService(SolrClient solrClient, ObjectMapper objectMapper) { * @return JSON string containing the schema representation */ @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") + @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)); @@ -265,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", 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"); @@ -300,20 +311,25 @@ public SchemaUpdateResult addFields(@McpToolParam(description = "Solr collection } @PreAuthorize("isAuthenticated()") - @McpTool(name = "add-field-types", 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"); @@ -425,9 +441,14 @@ private static void requireNonEmpty(List list, String name) { @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) { + @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. @@ -464,11 +485,23 @@ public String viewSchemaPrompt( @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.") + @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) { + @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."); 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 18e9f002..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 @@ -218,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. @@ -244,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, @@ -304,10 +311,19 @@ public SearchResponse search(@McpToolParam(description = "Solr collection to que @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.") + @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) { + @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: 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/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) From 8aa7ab7ae60cd34f11e234b07aeecfc13fca04f0 Mon Sep 17 00:00:00 2001 From: adityamparikh Date: Wed, 27 May 2026 16:41:43 -0400 Subject: [PATCH 27/27] fix(native): let native-http boot when no OAuth2 issuer URL is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The secure-by-default change in 015eedd (PR #125) broke DockerImageHttpIntegrationTest under -Pnative -Pprofile=http: AOT bakes in the http.security.enabled=true SecurityFilterChain bean (the @ConditionalOnProperty decision is frozen at build time), and McpServerOAuth2Configurer.init() builds a NimbusJwtDecoder eagerly against the configured issuer URL during bean instantiation. With the placeholder default https://your-auth0-domain.auth0.com/, native-http exited code 1 before /actuator/health came up, while the JVM image was unaffected because its conditional is evaluated at runtime. Three changes work together: - application-http.properties: drop the placeholder Auth0 default. An unset OAUTH2_ISSUER_URI now resolves to an empty string instead of a URL that will never resolve. - HttpSecurityConfiguration: skip the .with(McpServerOAuth2Configurer ...) call when issuerUrl is blank. The authorizeHttpRequests() rules still apply, so every non-permitAll endpoint returns 401/403 — the chain is locked down, just without a bearer-token validator. Production deployments that actually configure OAUTH2_ISSUER_URI continue to wire the full validator path. - DockerImageHttpIntegrationTest: no longer needs an HTTP_SECURITY_ENABLED override (it would be ineffective in native anyway since the conditional is AOT-baked). Comment documents why no issuer URL is provided. Verified locally by rebuilding solr-mcp:1.0.0-SNAPSHOT-native-http and running it with no OAUTH2_ISSUER_URI — /actuator/health returns {"status":"UP"} where it previously crashed with "Unable to resolve the Configuration with the provided Issuer". Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: adityamparikh --- .../security/HttpSecurityConfiguration.java | 45 ++++++++++++------- .../resources/application-http.properties | 7 ++- .../DockerImageHttpIntegrationTest.java | 7 +++ 3 files changed, 42 insertions(+), 17 deletions(-) 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/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/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/")