diff --git a/README.md b/README.md index a6234f96..0e81a36f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,9 @@ Using a different client, or want STDIO/HTTP/Docker options? See the per-client | `add-fields` | Add fields to a collection schema (additive only; existing fields cannot be modified) | | `add-field-types` | Add field types — custom analyzers, `DenseVectorField` for semantic search, etc. | | `get-schema` | Retrieve schema information for a collection | +| `list-aliases` | List all Solr aliases and the collections they point to | +| `create-alias` | Create or update a Solr alias pointing to one or more collections | +| `delete-alias` | Delete a Solr alias (underlying collections are not affected) | Every tool advertises MCP behavior hints (`readOnlyHint`, `destructiveHint`, `idempotentHint`) so clients can build sensible approval UX — `search` and the metadata tools are read-only, indexing is destructive but idempotent, schema modification is additive. diff --git a/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java b/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java new file mode 100644 index 00000000..34f7561b --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/collection/AliasResult.java @@ -0,0 +1,43 @@ +/* + * 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.collection; + +import com.fasterxml.jackson.annotation.JsonFormat; +import java.util.Date; +import org.jspecify.annotations.Nullable; + +/** + * Result record for alias management operations. + * + *

+ * Returned by {@link AliasService} methods to communicate the outcome of + * create, update, and delete operations on Solr aliases. + * + * @param aliasName + * the alias that was operated on + * @param collections + * the target collection(s) (null for delete operations) + * @param success + * whether the operation completed successfully + * @param message + * human-readable description of the outcome + * @param timestamp + * when the operation was performed + */ +public record AliasResult(String aliasName, @Nullable String collections, boolean success, String message, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") Date timestamp) { +} diff --git a/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java new file mode 100644 index 00000000..bf024844 --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/collection/AliasService.java @@ -0,0 +1,215 @@ +/* + * 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.collection; + +import io.micrometer.observation.annotation.Observed; +import java.io.IOException; +import java.util.Date; +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.response.CollectionAdminResponse; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; + +/** + * Spring Service providing Solr alias management capabilities for MCP clients. + * + *

+ * Aliases are virtual collection names that point to one or more physical + * collections. They enable zero-downtime reindexing, blue-green deployments, + * and read/write separation by allowing applications to reference a stable name + * while the underlying collection is swapped transparently. + * + *

+ * Core Capabilities: + * + *

+ * + * @see CollectionAdminRequest + */ +@Service +@Observed +public class AliasService { + + /** + * Error message for blank alias name validation + */ + private static final String BLANK_ALIAS_NAME_ERROR = "Alias name must not be blank"; + + /** + * Error message for blank collections validation + */ + private static final String BLANK_COLLECTIONS_ERROR = "Collections must not be blank"; + + /** + * SolrJ client for communicating with Solr server + */ + private final SolrClient solrClient; + + /** + * Constructs a new AliasService with the required dependencies. + * + * @param solrClient + * the SolrJ client instance for communicating with Solr + */ + public AliasService(SolrClient solrClient) { + this.solrClient = solrClient; + } + + /** + * Lists all aliases defined in the Solr cluster. + * + *

+ * Returns a map where each key is an alias name and the corresponding value is + * the comma-separated list of collection names that the alias points to. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked by AI clients with natural language requests like "list all aliases", + * "what aliases exist?", or "show me alias mappings". + * + * @return a map of alias names to their target collection(s); never null + * (returns an empty map when no aliases are defined) + * @throws SolrServerException + * if there are errors communicating with Solr + * @throws IOException + * if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "list-aliases", + annotations = @McpTool.McpAnnotations(readOnlyHint = true), + description = "List all Solr aliases and the collections they point to") + public Map listAliases() throws SolrServerException, IOException { + CollectionAdminRequest.ListAliases request = new CollectionAdminRequest.ListAliases(); + CollectionAdminResponse response = request.process(solrClient); + Map aliases = response.getAliases(); + return aliases != null ? aliases : Map.of(); + } + + /** + * Creates or updates a Solr alias pointing to one or more collections. + * + *

+ * If the alias already exists, it is updated to point to the new collection(s). + * This is the mechanism for zero-downtime collection swaps: reindex into a new + * collection, then update the alias to point to it. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked with requests like "create an alias ORDERS pointing to ORDERS_V2" or + * "swap the LIVE alias to PRODUCTS_V3". + * + * @param aliasName + * the name of the alias to create or update (must not be blank) + * @param collections + * comma-separated list of target collection names (must not be + * blank) + * @return result describing the outcome of the operation + * @throws IllegalArgumentException + * if aliasName or collections is blank + * @throws SolrServerException + * if Solr returns an error + * @throws IOException + * if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "create-alias", + annotations = @McpTool.McpAnnotations(idempotentHint = true), + description = "Create or update a Solr alias pointing to one or more collections. " + + "If the alias already exists, it is updated to point to the new collection(s).") + public AliasResult createAlias( + @McpToolParam(description = "Name of the alias to create or update") String aliasName, + @McpToolParam( + description = "Comma-separated list of collection names the alias should point to") String collections) + throws SolrServerException, IOException { + + if (aliasName == null || aliasName.isBlank()) { + throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); + } + if (collections == null || collections.isBlank()) { + throw new IllegalArgumentException(BLANK_COLLECTIONS_ERROR); + } + + CollectionAdminRequest.CreateAlias request = CollectionAdminRequest.createAlias(aliasName, collections); + CollectionAdminResponse response = request.process(solrClient); + boolean success = response.getStatus() == 0; + + return new AliasResult(aliasName, collections, success, + success ? "Alias created/updated successfully" : "Alias creation/update failed", new Date()); + } + + /** + * Deletes an existing Solr alias. + * + *

+ * Removes the alias definition only — the underlying collection(s) are not + * affected. After deletion, the alias name is no longer resolvable. + * + *

+ * MCP Tool Usage: + * + *

+ * Invoked with requests like "delete the stale alias ORDERS_TEST" or "remove + * alias OLD_PRODUCTS". + * + * @param aliasName + * the name of the alias to delete (must not be blank) + * @return result describing the outcome of the operation + * @throws IllegalArgumentException + * if aliasName is blank + * @throws SolrServerException + * if Solr returns an error + * @throws IOException + * if there are I/O errors during communication + */ + @PreAuthorize("isAuthenticated()") + @McpTool( + name = "delete-alias", + annotations = @McpTool.McpAnnotations(destructiveHint = true), + description = "Delete a Solr alias. The underlying collection(s) are not affected.") + public AliasResult deleteAlias(@McpToolParam(description = "Name of the alias to delete") String aliasName) + throws SolrServerException, IOException { + + if (aliasName == null || aliasName.isBlank()) { + throw new IllegalArgumentException(BLANK_ALIAS_NAME_ERROR); + } + + CollectionAdminRequest.DeleteAlias request = CollectionAdminRequest.deleteAlias(aliasName); + CollectionAdminResponse response = request.process(solrClient); + boolean success = response.getStatus() == 0; + + return new AliasResult(aliasName, null, success, + success ? "Alias deleted successfully" : "Alias deletion failed", new Date()); + } +} 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 2390626b..26fbe702 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 @@ -65,6 +65,7 @@ public SolrNativeHints() { * image. */ private static final List MCP_RESPONSE_RECORDS = List.of( + "org.apache.solr.mcp.server.collection.AliasResult", "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", diff --git a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java new file mode 100644 index 00000000..7d7ba8fd --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceIntegrationTest.java @@ -0,0 +1,156 @@ +/* + * 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.collection; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.apache.solr.mcp.server.TestcontainersConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration tests for {@link AliasService} using Testcontainers with a real + * Solr instance. + * + *

+ * Tests create, list, update, and delete alias operations against a live Solr + * cluster. + */ +@SpringBootTest +@Import(TestcontainersConfiguration.class) +@Tag("integration") +@Testcontainers(disabledWithoutDocker = true) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class AliasServiceIntegrationTest { + + private static final String TEST_COLLECTION = "alias_test_collection"; + private static final String TEST_COLLECTION_2 = "alias_test_collection_2"; + private static final String TEST_ALIAS = "alias_test_alias"; + private static final String TEST_ALIAS_2 = "alias_test_alias_2"; + private static final String TEST_ALIAS_3 = "alias_test_alias_3"; + + @Autowired + private AliasService aliasService; + + @Autowired + private CollectionService collectionService; + + @BeforeAll + void setupCollection() throws Exception { + CollectionCreationResult created = collectionService.createCollection(TEST_COLLECTION, null, null, null); + assertThat(created.success()).as("Collection creation should succeed: %s", created.message()).isTrue(); + CollectionCreationResult created2 = collectionService.createCollection(TEST_COLLECTION_2, null, null, null); + assertThat(created2.success()).as("Collection 2 creation should succeed: %s", created2.message()).isTrue(); + } + + @Test + @Order(1) + void listAliases_initiallyEmpty() throws Exception { + Map aliases = aliasService.listAliases(); + // No aliases pointing to our test collection initially + assertThat(aliases).doesNotContainKey(TEST_ALIAS); + } + + @Test + @Order(2) + void createAlias_createsNewAlias() throws Exception { + AliasResult result = aliasService.createAlias(TEST_ALIAS, TEST_COLLECTION); + + assertThat(result.aliasName()).isEqualTo(TEST_ALIAS); + assertThat(result.collections()).isEqualTo(TEST_COLLECTION); + assertThat(result.success()).isTrue(); + assertThat(result.timestamp()).isNotNull(); + } + + @Test + @Order(3) + void listAliases_containsCreatedAlias() throws Exception { + Map aliases = aliasService.listAliases(); + + assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION); + } + + @Test + @Order(4) + void createAlias_updatesExistingAlias() throws Exception { + // Update alias to point to a different collection + AliasResult result = aliasService.createAlias(TEST_ALIAS, TEST_COLLECTION_2); + + assertThat(result.success()).isTrue(); + assertThat(result.collections()).isEqualTo(TEST_COLLECTION_2); + + // Verify the alias now points to the second collection + Map aliases = aliasService.listAliases(); + assertThat(aliases).containsEntry(TEST_ALIAS, TEST_COLLECTION_2); + } + + @Test + @Order(5) + void createAlias_multipleAliasesCanExist() throws Exception { + // Assert only on aliases this test creates itself. Asserting on TEST_ALIAS + // here would couple the expectation to whichever collection an earlier + // test last repointed it at. + AliasResult first = aliasService.createAlias(TEST_ALIAS_2, TEST_COLLECTION); + AliasResult second = aliasService.createAlias(TEST_ALIAS_3, TEST_COLLECTION); + assertThat(first.success()).isTrue(); + assertThat(second.success()).isTrue(); + + Map aliases = aliasService.listAliases(); + assertThat(aliases).containsEntry(TEST_ALIAS_2, TEST_COLLECTION).containsEntry(TEST_ALIAS_3, TEST_COLLECTION); + + // TEST_ALIAS_2 is left in place for the deletion tests that follow; + // TEST_ALIAS_3 is this test's own fixture, so clean it up here. + assertThat(aliasService.deleteAlias(TEST_ALIAS_3).success()).isTrue(); + } + + @Test + @Order(6) + void deleteAlias_removesAlias() throws Exception { + AliasResult result = aliasService.deleteAlias(TEST_ALIAS); + + assertThat(result.aliasName()).isEqualTo(TEST_ALIAS); + assertThat(result.collections()).isNull(); + assertThat(result.success()).isTrue(); + + // Verify it's gone + Map aliases = aliasService.listAliases(); + assertThat(aliases).doesNotContainKey(TEST_ALIAS); + // But the other alias still exists + assertThat(aliases).containsEntry(TEST_ALIAS_2, TEST_COLLECTION); + } + + @Test + @Order(7) + void deleteAlias_cleanupSecondAlias() throws Exception { + AliasResult result = aliasService.deleteAlias(TEST_ALIAS_2); + assertThat(result.success()).isTrue(); + + Map aliases = aliasService.listAliases(); + assertThat(aliases).doesNotContainKey(TEST_ALIAS_2); + } +} diff --git a/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java new file mode 100644 index 00000000..bf1f3262 --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/collection/AliasServiceTest.java @@ -0,0 +1,173 @@ +/* + * 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.collection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; +import org.apache.solr.client.solrj.SolrClient; +import org.apache.solr.common.util.NamedList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledInNativeImage; + +/** + * Unit tests for {@link AliasService}. + */ +@DisabledInNativeImage +class AliasServiceTest { + + private SolrClient solrClient; + private AliasService aliasService; + + @BeforeEach + void setUp() { + solrClient = mock(SolrClient.class); + aliasService = new AliasService(solrClient); + } + + @Nested + @DisplayName("list-aliases") + class ListAliases { + + @Test + @DisplayName("returns aliases map when aliases exist") + void returnsAliasesWhenPresent() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + responseData.add("aliases", Map.of("ORDERS", "ORDERS_V2", "PRODUCTS", "PRODUCTS_V1")); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + Map result = aliasService.listAliases(); + + // Then + assertThat(result).containsEntry("ORDERS", "ORDERS_V2").containsEntry("PRODUCTS", "PRODUCTS_V1").hasSize(2); + } + + @Test + @DisplayName("returns empty map when no aliases exist") + void returnsEmptyMapWhenNoAliases() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + Map result = aliasService.listAliases(); + + // Then + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("create-alias") + class CreateAlias { + + @Test + @DisplayName("creates alias successfully") + void createsAliasSuccessfully() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + responseData.add("responseHeader", new NamedList<>(Map.of("status", 0))); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + AliasResult result = aliasService.createAlias("ORDERS", "ORDERS_V2"); + + // Then + assertThat(result.aliasName()).isEqualTo("ORDERS"); + assertThat(result.collections()).isEqualTo("ORDERS_V2"); + assertThat(result.success()).isTrue(); + assertThat(result.message()).contains("successfully"); + assertThat(result.timestamp()).isNotNull(); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is blank") + void throwsWhenAliasNameBlank() { + assertThatThrownBy(() -> aliasService.createAlias("", "ORDERS_V2")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is null") + void throwsWhenAliasNameNull() { + assertThatThrownBy(() -> aliasService.createAlias(null, "ORDERS_V2")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when collections is blank") + void throwsWhenCollectionsBlank() { + assertThatThrownBy(() -> aliasService.createAlias("ORDERS", "")) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when collections is null") + void throwsWhenCollectionsNull() { + assertThatThrownBy(() -> aliasService.createAlias("ORDERS", null)) + .isInstanceOf(IllegalArgumentException.class).hasMessage("Collections must not be blank"); + } + } + + @Nested + @DisplayName("delete-alias") + class DeleteAlias { + + @Test + @DisplayName("deletes alias successfully") + void deletesAliasSuccessfully() throws Exception { + // Given + NamedList responseData = new NamedList<>(); + responseData.add("responseHeader", new NamedList<>(Map.of("status", 0))); + when(solrClient.request(any(), nullable(String.class))).thenReturn(responseData); + + // When + AliasResult result = aliasService.deleteAlias("ORDERS"); + + // Then + assertThat(result.aliasName()).isEqualTo("ORDERS"); + assertThat(result.collections()).isNull(); + assertThat(result.success()).isTrue(); + assertThat(result.message()).contains("deleted"); + assertThat(result.timestamp()).isNotNull(); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is blank") + void throwsWhenAliasNameBlank() { + assertThatThrownBy(() -> aliasService.deleteAlias("")).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Alias name must not be blank"); + } + + @Test + @DisplayName("throws IllegalArgumentException when alias name is null") + void throwsWhenAliasNameNull() { + assertThatThrownBy(() -> aliasService.deleteAlias(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Alias name must not be blank"); + } + } +}