diff --git a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java index 0fc720b..d6fe567 100644 --- a/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java +++ b/src/main/java/org/apache/solr/mcp/server/config/SolrConfig.java @@ -23,6 +23,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; /** * Spring Configuration class for Apache Solr client setup and connection @@ -170,6 +171,11 @@ JsonResponseParser jsonResponseParser(ObjectMapper objectMapper) { return new JsonResponseParser(objectMapper); } + @Bean + RestClient restClient(RestClient.Builder builder) { + return builder.build(); + } + @Bean SolrClient solrClient(SolrConfigurationProperties properties, JsonResponseParser jsonResponseParser) { String url = properties.url(); diff --git a/src/main/java/org/apache/solr/mcp/server/refguide/RefGuideService.java b/src/main/java/org/apache/solr/mcp/server/refguide/RefGuideService.java new file mode 100644 index 0000000..9c4b12d --- /dev/null +++ b/src/main/java/org/apache/solr/mcp/server/refguide/RefGuideService.java @@ -0,0 +1,133 @@ +/* + * 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.refguide; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +/** + * Service for searching the Solr Reference Guide. + */ +@Service +public class RefGuideService { + + private static final String SITEMAP_URL = "https://solr.apache.org/guide/sitemap.xml"; + private static final Pattern LOC_PATTERN = Pattern.compile("(.*?)"); + + private final RestClient restClient; + + public RefGuideService(RestClient restClient) { + this.restClient = restClient; + } + + /** + * Searches the Solr Reference Guide for the specified query and version. + * + * @param query + * The search query + * @param version + * The Solr version (e.g., "9.10", "8.11", or "latest"). Defaults to + * "latest" if not specified. + * @return A list of relevant URLs from the Solr Reference Guide + */ + @PreAuthorize("isAuthenticated()") + @McpTool(name = "searchRefGuide", description = "Search the Solr Reference Guide for information about a specific version or the latest version.") + public List searchRefGuide( + @McpToolParam(description = "The search query (e.g., 'circuit breakers', 'indexing')") String query, + @McpToolParam(description = "The Solr version (e.g., '9.10', '8.11', 'latest'). Defaults to 'latest'", required = false) String version) { + + if (isVersionLessThan9(version)) { + return List.of( + "https://archive.apache.org/dist/lucene/solr/ref-guide/apache-solr-ref-guide-" + version + ".pdf"); + } + + final String targetVersion = (version == null || version.isBlank()) ? "latest" : version.replace('.', '_'); + final String sitemapContent = restClient.get().uri(SITEMAP_URL).retrieve().body(String.class); + + if (sitemapContent == null) { + return List.of(); + } + + final List urls = new ArrayList<>(); + final Matcher matcher = LOC_PATTERN.matcher(sitemapContent); + final String lowerQuery = query.toLowerCase(); + + while (matcher.find()) { + String url = matcher.group(1); + // Filter by version and query + if (url.contains("/" + targetVersion + "/") + || (targetVersion.equals("latest") && url.contains("/latest/"))) { + if (url.toLowerCase().contains(lowerQuery.replace(' ', '-')) + || url.toLowerCase().contains(lowerQuery.replace(' ', '_'))) { + urls.add(url); + } + } + } + + // Fallback: if no direct match, try matching individual words + if (urls.isEmpty()) { + String[] words = lowerQuery.split("\\s+"); + matcher.reset(); + while (matcher.find()) { + String url = matcher.group(1); + if (url.contains("/" + targetVersion + "/") || (targetVersion.equals("latest") && url.contains("/latest/"))) { + boolean allMatch = true; + for (String word : words) { + if (!url.toLowerCase().contains(word)) { + allMatch = false; + break; + } + } + if (allMatch) { + urls.add(url); + } + } + } + } + + return urls.stream().limit(10).toList(); + + } + + private boolean isVersionLessThan9(final String version) { + + if (version == null || version.isBlank() || "latest".equalsIgnoreCase(version)) { + return false; + } + + try { + String[] parts = version.split("\\."); + if (parts.length > 0) { + int major = Integer.parseInt(parts[0]); + return major < 9; + } + } catch (NumberFormatException e) { + // ignore and say not less than 9 + } + + return false; + + } + +} 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 af79754..58ccead 100644 --- a/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java +++ b/src/test/java/org/apache/solr/mcp/server/McpToolRegistrationTest.java @@ -25,6 +25,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.refguide.RefGuideService; import org.apache.solr.mcp.server.search.SearchService; import org.junit.jupiter.api.Test; import org.springaicommunity.mcp.annotation.McpTool; @@ -136,6 +137,26 @@ void testSchemaServiceHasToolAnnotations() { } } + @Test + void testRefGuideServiceHasToolAnnotations() { + // Get all methods from RefGuideService + Method[] methods = RefGuideService.class.getDeclaredMethods(); + + // Find methods with @McpTool annotation + List mcpToolMethods = Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).toList(); + + // Verify at least one method has the annotation + assertFalse(mcpToolMethods.isEmpty(), + "RefGuideService should have at least one method with @McpTool annotation"); + + // Verify each tool has proper annotations + for (Method method : mcpToolMethods) { + McpTool toolAnnotation = method.getAnnotation(McpTool.class); + assertNotNull(toolAnnotation.description(), "Tool description should not be null"); + assertFalse(toolAnnotation.description().isBlank(), "Tool description should not be blank"); + } + } + @Test void testAllMcpToolsHaveUniqueNames() { // Collect all MCP tool names from all services @@ -153,6 +174,9 @@ void testAllMcpToolsHaveUniqueNames() { // SchemaService addToolNames(SchemaService.class, toolNames); + // RefGuideService + addToolNames(RefGuideService.class, toolNames); + // Verify all tool names are unique long uniqueCount = toolNames.stream().distinct().count(); assertEquals(toolNames.size(), uniqueCount, diff --git a/src/test/java/org/apache/solr/mcp/server/refguide/RefGuideServiceTest.java b/src/test/java/org/apache/solr/mcp/server/refguide/RefGuideServiceTest.java new file mode 100644 index 0000000..b9a9cb8 --- /dev/null +++ b/src/test/java/org/apache/solr/mcp/server/refguide/RefGuideServiceTest.java @@ -0,0 +1,125 @@ +/* + * 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.refguide; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.client.RestClient; + +@ExtendWith(MockitoExtension.class) +class RefGuideServiceTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private RestClient restClient; + + private RefGuideService refGuideService; + + private static final String SITEMAP_CONTENT = """ + + + https://solr.apache.org/guide/solr/latest/configuration-guide/circuit-breakers.html + https://solr.apache.org/guide/solr/latest/indexing-guide/indexing-with-tika.html + https://solr.apache.org/guide/solr/9_10/configuration-guide/circuit-breakers.html + https://solr.apache.org/guide/solr/8_11/indexing-guide/indexing-with-tika.html + + """; + + @BeforeEach + void setUp() { + refGuideService = new RefGuideService(restClient); + lenient().when(restClient.get().uri(anyString()).retrieve().body(String.class)).thenReturn(SITEMAP_CONTENT); + } + + @Test + void testSearchRefGuide_LatestVersion() { + // When + List results = refGuideService.searchRefGuide("circuit breakers", "latest"); + + // Then + assertNotNull(results); + assertEquals(1, results.size()); + assertTrue(results.get(0).contains("/latest/")); + assertTrue(results.get(0).contains("circuit-breakers.html")); + } + + @Test + void testSearchRefGuide_SpecificVersion() { + // When + List results = refGuideService.searchRefGuide("circuit breakers", "9.10"); + + // Then + assertNotNull(results); + assertEquals(1, results.size()); + assertTrue(results.get(0).contains("/9_10/")); + assertTrue(results.get(0).contains("circuit-breakers.html")); + } + + @Test + void testSearchRefGuide_DefaultVersion() { + // When + List results = refGuideService.searchRefGuide("tika", null); + + // Then + assertNotNull(results); + assertEquals(1, results.size()); + assertTrue(results.get(0).contains("/latest/")); + assertTrue(results.get(0).contains("indexing-with-tika.html")); + } + + @Test + void testSearchRefGuide_MultipleWords() { + // When + List results = refGuideService.searchRefGuide("indexing tika", "8.11"); + + // Then + assertNotNull(results); + assertEquals(1, results.size()); + assertTrue(results.get(0).contains("/dist/lucene/solr/ref-guide/")); + assertTrue(results.get(0).contains("apache-solr-ref-guide-8.11.pdf")); + } + + @Test + void testSearchRefGuide_VersionLessThanNine() { + // When + List results = refGuideService.searchRefGuide("anything", "7.7"); + + // Then + assertNotNull(results); + assertEquals(1, results.size()); + assertEquals("https://archive.apache.org/dist/lucene/solr/ref-guide/apache-solr-ref-guide-7.7.pdf", + results.get(0)); + } + + @Test + void testSearchRefGuide_NoResults() { + // When + List results = refGuideService.searchRefGuide("nonexistent topic", "latest"); + + // Then + assertNotNull(results); + assertTrue(results.isEmpty()); + } +}