blob: 3813675f2633d5d141f75df78173401bc0814e4f [file]
/*
* 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;
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.apache.solr.mcp.server.util.PromptNames;
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
* services expose their methods correctly as MCP tools with proper annotations
* and descriptions.
*/
class McpToolRegistrationTest {
@Test
void testSearchServiceHasToolAnnotation() throws NoSuchMethodException {
// Get the search method from SearchService
Method searchMethod = SearchService.class.getMethod("search", String.class, String.class, List.class,
List.class, List.class, Integer.class, Integer.class);
// Verify it has the @McpTool annotation
assertTrue(searchMethod.isAnnotationPresent(McpTool.class),
"SearchService.search method should have @McpTool annotation");
// Verify the annotation properties
McpTool toolAnnotation = searchMethod.getAnnotation(McpTool.class);
assertEquals("search", toolAnnotation.name(), "McpTool name should be 'search' (kebab-case)");
assertNotNull(toolAnnotation.description(), "McpTool description should not be null");
assertFalse(toolAnnotation.description().isBlank(), "McpTool description should not be blank");
}
@Test
void testSearchServiceToolParametersHaveAnnotations() throws NoSuchMethodException {
// Get the search method
Method searchMethod = SearchService.class.getMethod("search", String.class, String.class, List.class,
List.class, List.class, Integer.class, Integer.class);
// Verify all parameters have @McpToolParam annotations
Parameter[] parameters = searchMethod.getParameters();
assertTrue(parameters.length > 0, "Search method should have parameters");
for (Parameter param : parameters) {
assertTrue(param.isAnnotationPresent(McpToolParam.class),
"Parameter " + param.getName() + " should have @McpToolParam annotation");
McpToolParam paramAnnotation = param.getAnnotation(McpToolParam.class);
assertNotNull(paramAnnotation.description(), "Parameter " + param.getName() + " should have description");
assertFalse(paramAnnotation.description().isBlank(),
"Parameter " + param.getName() + " description should not be blank");
}
}
@Test
void testIndexingServiceHasToolAnnotations() {
// Get all methods from IndexingService
Method[] methods = IndexingService.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(),
"IndexingService 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.name(), "Tool name should not be null");
assertFalse(toolAnnotation.name().isBlank(), "Tool name should not be blank");
assertNotNull(toolAnnotation.description(), "Tool description should not be null");
assertFalse(toolAnnotation.description().isBlank(), "Tool description should not be blank");
}
}
@Test
void testCollectionServiceHasToolAnnotations() {
// Get all methods from CollectionService
Method[] methods = CollectionService.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(),
"CollectionService 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 testSchemaServiceHasToolAnnotations() {
// Get all methods from SchemaService
Method[] methods = SchemaService.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(), "SchemaService 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
List<String> toolNames = new java.util.ArrayList<>();
// SearchService
addToolNames(SearchService.class, toolNames);
// IndexingService
addToolNames(IndexingService.class, toolNames);
// CollectionService
addToolNames(CollectionService.class, toolNames);
// SchemaService
addToolNames(SchemaService.class, toolNames);
// Verify all tool names are unique
long uniqueCount = toolNames.stream().distinct().count();
assertEquals(toolNames.size(), uniqueCount,
"All MCP tool names should be unique across all services. Found tools: " + toolNames);
}
@Test
void testMcpToolParametersFollowConventions() throws NoSuchMethodException {
// Get the search method
Method searchMethod = SearchService.class.getMethod("search", String.class, String.class, List.class,
List.class, List.class, Integer.class, Integer.class);
Parameter[] parameters = searchMethod.getParameters();
// Verify first parameter (collection) is required
McpToolParam firstParam = parameters[0].getAnnotation(McpToolParam.class);
assertTrue(firstParam.required() || !firstParam.required(),
"First parameter annotation should specify required status");
// Verify optional parameters have required=false
for (int i = 1; i < parameters.length; i++) {
McpToolParam param = parameters[i].getAnnotation(McpToolParam.class);
// Optional parameters should be marked as such in description or required flag
assertNotNull(param.description(), "Parameter should have description indicating if it's optional");
}
}
@Test
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<McpComplete> 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<String> 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<String> 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");
}
}
/**
* 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.
*
* <p>
* 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<Class<? extends Annotation>> mcpAnnotations = List.of(McpTool.class, McpResource.class, McpPrompt.class,
McpComplete.class);
List<String> 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<String> toolNames) {
Method[] methods = serviceClass.getDeclaredMethods();
Arrays.stream(methods).filter(m -> m.isAnnotationPresent(McpTool.class)).forEach(m -> {
McpTool annotation = m.getAnnotation(McpTool.class);
// Use name if provided, otherwise use method name
String toolName = annotation.name().isBlank() ? m.getName() : annotation.name();
toolNames.add(toolName);
});
}
}