Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
133 changes: 133 additions & 0 deletions src/main/java/org/apache/solr/mcp/server/refguide/RefGuideService.java
Original file line number Diff line number Diff line change
@@ -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("<loc>(.*?)</loc>");

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<String> 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<String> 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;

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Method> 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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://solr.apache.org/guide/solr/latest/configuration-guide/circuit-breakers.html</loc></url>
<url><loc>https://solr.apache.org/guide/solr/latest/indexing-guide/indexing-with-tika.html</loc></url>
<url><loc>https://solr.apache.org/guide/solr/9_10/configuration-guide/circuit-breakers.html</loc></url>
<url><loc>https://solr.apache.org/guide/solr/8_11/indexing-guide/indexing-with-tika.html</loc></url>
</urlset>
""";

@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<String> 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<String> 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<String> 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<String> 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<String> 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<String> results = refGuideService.searchRefGuide("nonexistent topic", "latest");

// Then
assertNotNull(results);
assertTrue(results.isEmpty());
}
}