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());
+ }
+}