diff --git a/README.md b/README.md index fff7543..6a53892 100644 --- a/README.md +++ b/README.md @@ -411,8 +411,8 @@ public class CalculatorToolProvider { @McpTool(name = "calculate-area", description = "Calculate the area of a rectangle", + title = "Rectangle Area Calculator", // Human-readable display name annotations = @McpTool.McpAnnotations( - title = "Rectangle Area Calculator", readOnlyHint = true, destructiveHint = false, idempotentHint = true @@ -490,6 +490,62 @@ public class CalculatorToolProvider { } ``` +#### Tool Title Attribute + +The `@McpTool` annotation supports a `title` attribute that provides a human-readable display name for tools. This is intended for UI and end-user contexts, optimized to be easily understood even by those unfamiliar with domain-specific terminology. + +**Title Precedence Order:** +1. If the `title` attribute is explicitly set, it takes precedence +2. If not set but `annotations.title` exists, that value is used +3. If neither is provided, the tool's `name` is used as the title +4. If the `name` is not set the method name is used as the title + +Example usage: + +```java +// Using the title attribute directly +@McpTool(name = "calc-area", + description = "Calculate rectangle area", + title = "Rectangle Area Calculator") // Human-friendly display name +public double calculateArea(double width, double height) { + return width * height; +} + +// Title attribute takes precedence over annotations.title +@McpTool(name = "data-processor", + description = "Process complex data", + title = "Data Processor", // This takes precedence + annotations = @McpTool.McpAnnotations( + title = "Complex Data Handler" // This is overridden + )) +public String processData(String input) { + return process(input); +} + +// Using annotations.title when title attribute is not set +@McpTool(name = "file-converter", + description = "Convert file formats", + annotations = @McpTool.McpAnnotations( + title = "File Format Converter" // This will be used as title + )) +public String convertFile(String filePath) { + return convert(filePath); +} + +// Falls back to name when no title is provided +@McpTool(name = "simple-tool", + description = "A simple tool") +public String simpleTool(String input) { + // Title will be "simple-tool" + return input; +} +``` + +The title is particularly useful for: +- Displaying tools in user interfaces with friendly names +- Providing clear, non-technical names for end users +- Maintaining backward compatibility (tools without titles continue to work) + #### CallToolRequest Support The library supports special `CallToolRequest` parameters in tool methods, enabling dynamic schema handling at runtime. This is useful when you need to: diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpTool.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpTool.java index 3a1876d..040e7b2 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpTool.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpTool.java @@ -38,6 +38,14 @@ */ boolean generateOutputSchema() default true; + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily + * understood, even by those unfamiliar with domain-specific terminology. If not + * provided, the name should be used for display (except for Tool, where + * annotations.title should be given precedence over using name, if present). + */ + String title() default ""; + /** * Additional properties describing a Tool to clients. * diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProvider.java index be1a526..12483ed 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProvider.java @@ -75,12 +75,12 @@ public List getToolSpecifications() { || Publisher.class.isAssignableFrom(method.getReturnType())) .map(mcpToolMethod -> { - var toolAnnotation = doGetMcpToolAnnotation(mcpToolMethod); + var toolJavaAnnotation = doGetMcpToolAnnotation(mcpToolMethod); - String toolName = Utils.hasText(toolAnnotation.name()) ? toolAnnotation.name() + String toolName = Utils.hasText(toolJavaAnnotation.name()) ? toolJavaAnnotation.name() : mcpToolMethod.getName(); - String toolDescrption = toolAnnotation.description(); + String toolDescrption = toolJavaAnnotation.description(); String inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); @@ -89,20 +89,36 @@ public List getToolSpecifications() { .description(toolDescrption) .inputSchema(inputSchema); + var title = toolJavaAnnotation.title(); + // Tool annotations - if (toolAnnotation.annotations() != null) { - var toolAnnotations = toolAnnotation.annotations(); + if (toolJavaAnnotation.annotations() != null) { + var toolAnnotations = toolJavaAnnotation.annotations(); toolBuilder.annotations(new McpSchema.ToolAnnotations(toolAnnotations.title(), toolAnnotations.readOnlyHint(), toolAnnotations.destructiveHint(), toolAnnotations.idempotentHint(), toolAnnotations.openWorldHint(), null)); + + // If not provided, the name should be used for display (except + // for Tool, where annotations.title should be given precedence + // over using name, if present). + if (!Utils.hasText(title)) { + title = toolAnnotations.title(); + } } + // If not provided, the name should be used for display (except + // for Tool, where annotations.title should be given precedence + // over using name, if present). + if (!Utils.hasText(title)) { + title = toolName; + } + toolBuilder.title(title); + // Generate Output Schema from the method return type. // Output schema is not generated for primitive types, void, // CallToolResult, simple value types (String, etc.) // or if generateOutputSchema attribute is set to false. - - if (toolAnnotation.generateOutputSchema() + if (toolJavaAnnotation.generateOutputSchema() && !ReactiveUtils.isReactiveReturnTypeOfVoid(mcpToolMethod) && !ReactiveUtils.isReactiveReturnTypeOfCallToolResult(mcpToolMethod)) { diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProvider.java index 2e203a5..7498a7a 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProvider.java @@ -79,12 +79,12 @@ public List getToolSpecifications() { || Publisher.class.isAssignableFrom(method.getReturnType())) .map(mcpToolMethod -> { - var toolAnnotation = doGetMcpToolAnnotation(mcpToolMethod); + var toolJavaAnnotation = doGetMcpToolAnnotation(mcpToolMethod); - String toolName = Utils.hasText(toolAnnotation.name()) ? toolAnnotation.name() + String toolName = Utils.hasText(toolJavaAnnotation.name()) ? toolJavaAnnotation.name() : mcpToolMethod.getName(); - String toolDescrption = toolAnnotation.description(); + String toolDescrption = toolJavaAnnotation.description(); String inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); @@ -93,20 +93,37 @@ public List getToolSpecifications() { .description(toolDescrption) .inputSchema(inputSchema); + var title = toolJavaAnnotation.title(); + // Tool annotations - if (toolAnnotation.annotations() != null) { - var toolAnnotations = toolAnnotation.annotations(); + if (toolJavaAnnotation.annotations() != null) { + var toolAnnotations = toolJavaAnnotation.annotations(); toolBuilder.annotations(new McpSchema.ToolAnnotations(toolAnnotations.title(), toolAnnotations.readOnlyHint(), toolAnnotations.destructiveHint(), toolAnnotations.idempotentHint(), toolAnnotations.openWorldHint(), null)); + + // If not provided, the name should be used for display (except + // for Tool, where annotations.title should be given precedence + // over using name, if present). + if (!Utils.hasText(title)) { + title = toolAnnotations.title(); + } + } + + // If not provided, the name should be used for display (except + // for Tool, where annotations.title should be given precedence + // over using name, if present). + if (!Utils.hasText(title)) { + title = toolName; } + toolBuilder.title(title); // Generate Output Schema from the method return type. // Output schema is not generated for primitive types, void, // CallToolResult, simple value types (String, etc.) // or if generateOutputSchema attribute is set to false. - if (toolAnnotation.generateOutputSchema() + if (toolJavaAnnotation.generateOutputSchema() && !ReactiveUtils.isReactiveReturnTypeOfVoid(mcpToolMethod) && !ReactiveUtils.isReactiveReturnTypeOfCallToolResult(mcpToolMethod)) { diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProvider.java index fd492bd..2b0a942 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProvider.java @@ -22,15 +22,6 @@ import java.util.function.BiFunction; import java.util.stream.Stream; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springaicommunity.mcp.annotation.McpTool; -import org.springaicommunity.mcp.method.tool.ReactiveUtils; -import org.springaicommunity.mcp.method.tool.ReturnMode; -import org.springaicommunity.mcp.method.tool.SyncMcpToolMethodCallback; -import org.springaicommunity.mcp.method.tool.utils.ClassUtils; -import org.springaicommunity.mcp.method.tool.utils.JsonSchemaGenerator; - import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; @@ -38,6 +29,13 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.method.tool.ReturnMode; +import org.springaicommunity.mcp.method.tool.SyncMcpToolMethodCallback; +import org.springaicommunity.mcp.method.tool.utils.ClassUtils; +import org.springaicommunity.mcp.method.tool.utils.JsonSchemaGenerator; import reactor.core.publisher.Mono; /** @@ -72,12 +70,12 @@ public List getToolSpecifications() { .filter(method -> !Mono.class.isAssignableFrom(method.getReturnType())) .map(mcpToolMethod -> { - McpTool toolAnnotation = doGetMcpToolAnnotation(mcpToolMethod); + McpTool toolJavaAnnotation = doGetMcpToolAnnotation(mcpToolMethod); - String toolName = Utils.hasText(toolAnnotation.name()) ? toolAnnotation.name() + String toolName = Utils.hasText(toolJavaAnnotation.name()) ? toolJavaAnnotation.name() : mcpToolMethod.getName(); - String toolDescription = toolAnnotation.description(); + String toolDescription = toolJavaAnnotation.description(); // Check if method has CallToolRequest parameter boolean hasCallToolRequestParam = Arrays.stream(mcpToolMethod.getParameterTypes()) @@ -100,13 +98,30 @@ public List getToolSpecifications() { .description(toolDescription) .inputSchema(inputSchema); + var title = toolJavaAnnotation.title(); + // Tool annotations - if (toolAnnotation.annotations() != null) { - var toolAnnotations = toolAnnotation.annotations(); + if (toolJavaAnnotation.annotations() != null) { + var toolAnnotations = toolJavaAnnotation.annotations(); toolBuilder.annotations(new McpSchema.ToolAnnotations(toolAnnotations.title(), toolAnnotations.readOnlyHint(), toolAnnotations.destructiveHint(), toolAnnotations.idempotentHint(), toolAnnotations.openWorldHint(), null)); + + // If not provided, the name should be used for display (except + // for Tool, where annotations.title should be given precedence + // over using name, if present). + if (!Utils.hasText(title)) { + title = toolAnnotations.title(); + } + } + + // If not provided, the name should be used for display (except + // for Tool, where annotations.title should be given precedence + // over using name, if present). + if (!Utils.hasText(title)) { + title = toolName; } + toolBuilder.title(title); // ReactiveUtils.isReactiveReturnTypeOfCallToolResult(mcpToolMethod); @@ -115,7 +130,7 @@ public List getToolSpecifications() { // CallToolResult, simple value types (String, etc.) // or if generateOutputSchema attribute is set to false. Class methodReturnType = mcpToolMethod.getReturnType(); - if (toolAnnotation.generateOutputSchema() && methodReturnType != null + if (toolJavaAnnotation.generateOutputSchema() && methodReturnType != null && methodReturnType != CallToolResult.class && methodReturnType != Void.class && methodReturnType != void.class && !ClassUtils.isPrimitiveOrWrapper(methodReturnType) && !ClassUtils.isSimpleValueType(methodReturnType)) { diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProvider.java index dfb7e70..863e178 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProvider.java @@ -74,12 +74,12 @@ public List getToolSpecifications() { .filter(method -> !Mono.class.isAssignableFrom(method.getReturnType())) .map(mcpToolMethod -> { - var toolAnnotation = doGetMcpToolAnnotation(mcpToolMethod); + var toolJavaAnnotation = doGetMcpToolAnnotation(mcpToolMethod); - String toolName = Utils.hasText(toolAnnotation.name()) ? toolAnnotation.name() + String toolName = Utils.hasText(toolJavaAnnotation.name()) ? toolJavaAnnotation.name() : mcpToolMethod.getName(); - String toolDescrption = toolAnnotation.description(); + String toolDescrption = toolJavaAnnotation.description(); String inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); @@ -88,21 +88,39 @@ public List getToolSpecifications() { .description(toolDescrption) .inputSchema(inputSchema); + var title = toolJavaAnnotation.title(); + // Tool annotations - if (toolAnnotation.annotations() != null) { - var toolAnnotations = toolAnnotation.annotations(); + if (toolJavaAnnotation.annotations() != null) { + var toolAnnotations = toolJavaAnnotation.annotations(); toolBuilder.annotations(new McpSchema.ToolAnnotations(toolAnnotations.title(), toolAnnotations.readOnlyHint(), toolAnnotations.destructiveHint(), toolAnnotations.idempotentHint(), toolAnnotations.openWorldHint(), null)); + + // If not provided, the name should be used for display (except + // for Tool, where annotations.title should be given precedence + // over using name, if present). + if (!Utils.hasText(title)) { + title = toolAnnotations.title(); + } } - ReactiveUtils.isReactiveReturnTypeOfCallToolResult(mcpToolMethod); + // If not provided, the name should be used for display (except + // for Tool, where annotations.title should be given precedence + // over using name, if present). + if (!Utils.hasText(title)) { + title = toolName; + } + toolBuilder.title(title); + + // ReactiveUtils.isReactiveReturnTypeOfCallToolResult(mcpToolMethod); + // Generate Output Schema from the method return type. // Output schema is not generated for primitive types, void, // CallToolResult, simple value types (String, etc.) // or if generateOutputSchema attribute is set to false. Class methodReturnType = mcpToolMethod.getReturnType(); - if (toolAnnotation.generateOutputSchema() && methodReturnType != null + if (toolJavaAnnotation.generateOutputSchema() && methodReturnType != null && methodReturnType != CallToolResult.class && methodReturnType != Void.class && methodReturnType != void.class && !ClassUtils.isPrimitiveOrWrapper(methodReturnType) && !ClassUtils.isSimpleValueType(methodReturnType)) { diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProviderTests.java index d022d35..db24ac4 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProviderTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/AsyncMcpToolProviderTests.java @@ -31,6 +31,7 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.ToolAnnotations; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.test.StepVerifier; @@ -536,6 +537,338 @@ public Flux fluxHandlingTool(String input) { }).verifyComplete(); } + @Test + void testToolWithTitle() { + class TitleTool { + + @McpTool(name = "title-tool", description = "Tool with title", title = "Custom Title") + public Mono titleTool(String input) { + return Mono.just("Title: " + input); + } + + } + + TitleTool toolObject = new TitleTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("title-tool"); + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Custom Title"); + } + + @Test + void testToolTitlePrecedence() { + // Test that title attribute takes precedence over annotations.title + class TitlePrecedenceTool { + + @McpTool(name = "precedence-tool", description = "Tool with title precedence", title = "Title Attribute", + annotations = @McpTool.McpAnnotations(title = "Annotations Title")) + public Mono precedenceTool(String input) { + return Mono.just("Precedence: " + input); + } + + } + + TitlePrecedenceTool toolObject = new TitlePrecedenceTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // According to the implementation, title attribute takes precedence over + // annotations.title + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Title Attribute"); + } + + @Test + void testToolAnnotationsTitleUsedWhenNoTitleAttribute() { + // Test that annotations.title is used when title attribute is not provided + class AnnotationsTitleTool { + + @McpTool(name = "annotations-title-tool", description = "Tool with only annotations title", + annotations = @McpTool.McpAnnotations(title = "Annotations Title Only")) + public Mono annotationsTitleTool(String input) { + return Mono.just("Annotations title: " + input); + } + + } + + AnnotationsTitleTool toolObject = new AnnotationsTitleTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // When no title attribute is provided, annotations.title should be used + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Annotations Title Only"); + } + + @Test + void testToolWithoutTitleUsesName() { + class NoTitleTool { + + @McpTool(name = "no-title-tool", description = "Tool without title") + public Mono noTitleTool(String input) { + return Mono.just("No title: " + input); + } + + } + + NoTitleTool toolObject = new NoTitleTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // When no title is provided, the name should be used + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("no-title-tool"); + } + + @Test + void testToolWithAnnotations() { + class AnnotatedTool { + + @McpTool(name = "annotated-tool", description = "Tool with annotations", + annotations = @McpTool.McpAnnotations(title = "Annotated Tool", readOnlyHint = true, + destructiveHint = false, idempotentHint = true, openWorldHint = false)) + public Mono annotatedTool(String input) { + return Mono.just("Annotated: " + input); + } + + } + + AnnotatedTool toolObject = new AnnotatedTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("annotated-tool"); + assertThat(toolSpec.tool().title()).isEqualTo("Annotated Tool"); + + ToolAnnotations annotations = toolSpec.tool().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.title()).isEqualTo("Annotated Tool"); + assertThat(annotations.readOnlyHint()).isTrue(); + assertThat(annotations.destructiveHint()).isFalse(); + assertThat(annotations.idempotentHint()).isTrue(); + assertThat(annotations.openWorldHint()).isFalse(); + } + + @Test + void testToolWithDefaultAnnotations() { + class DefaultAnnotationsTool { + + @McpTool(name = "default-annotations-tool", description = "Tool with default annotations") + public Mono defaultAnnotationsTool(String input) { + return Mono.just("Default annotations: " + input); + } + + } + + DefaultAnnotationsTool toolObject = new DefaultAnnotationsTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + // With default annotations, the annotations object should still be created + ToolAnnotations annotations = toolSpec.tool().annotations(); + assertThat(annotations).isNotNull(); + // Check default values + assertThat(annotations.readOnlyHint()).isFalse(); + assertThat(annotations.destructiveHint()).isTrue(); + assertThat(annotations.idempotentHint()).isFalse(); + assertThat(annotations.openWorldHint()).isTrue(); + } + + @Test + void testToolWithCallToolRequestParameter() { + class CallToolRequestParamTool { + + @McpTool(name = "request-param-tool", description = "Tool with CallToolRequest parameter") + public Mono requestParamTool(CallToolRequest request, String additionalParam) { + return Mono.just("Request tool: " + request.name() + ", param: " + additionalParam); + } + + } + + CallToolRequestParamTool toolObject = new CallToolRequestParamTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("request-param-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with CallToolRequest parameter"); + + // The input schema should still be generated but should handle CallToolRequest + // specially + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + String schemaString = toolSpec.tool().inputSchema().toString(); + // Should contain the additional parameter but not the CallToolRequest + assertThat(schemaString).contains("additionalParam"); + } + + @Test + void testToolWithOnlyCallToolRequestParameter() { + class OnlyCallToolRequestTool { + + @McpTool(name = "only-request-tool", description = "Tool with only CallToolRequest parameter") + public Mono onlyRequestTool(CallToolRequest request) { + return Mono.just("Only request tool: " + request.name()); + } + + } + + OnlyCallToolRequestTool toolObject = new OnlyCallToolRequestTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("only-request-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with only CallToolRequest parameter"); + + // The input schema should be minimal when only CallToolRequest is present + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + } + + @Test + void testToolWithVoidReturnType() { + class VoidTool { + + @McpTool(name = "void-tool", description = "Tool with void return") + public Mono voidTool(String input) { + // Simulate some side effect + System.out.println("Processing: " + input); + return Mono.empty(); + } + + } + + VoidTool toolObject = new VoidTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("void-tool"); + // Output schema should not be generated for void return type + assertThat(toolSpec.tool().outputSchema()).isNull(); + + // Test that the handler works with Mono return type + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("void-tool", Map.of("input", "test")); + Mono result = toolSpec.callHandler().apply(exchange, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + // For Mono, the framework returns a "Done" message + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("\"Done\""); + }).verifyComplete(); + } + + @Test + void testToolWithPrimitiveReturnTypeNoOutputSchema() { + // Reactive methods can't return primitives directly, but can return wrapped + // primitives + class PrimitiveTool { + + @McpTool(name = "primitive-tool", description = "Tool with primitive return") + public Mono primitiveTool(String input) { + return Mono.just(input.length()); + } + + } + + PrimitiveTool toolObject = new PrimitiveTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("primitive-tool"); + // Output schema should not be generated for primitive wrapper types + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithStringReturnTypeNoOutputSchema() { + class StringTool { + + @McpTool(name = "string-tool", description = "Tool with String return") + public Mono stringTool(String input) { + return Mono.just("Result: " + input); + } + + } + + StringTool toolObject = new StringTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("string-tool"); + // Output schema should not be generated for simple value types like String + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithDisabledOutputSchemaGeneration() { + class CustomResult { + + public String message; + + public CustomResult(String message) { + this.message = message; + } + + } + + class NoOutputSchemaTool { + + @McpTool(name = "no-output-schema-tool", description = "Tool without output schema", + generateOutputSchema = false) + public Mono noOutputSchemaTool(String input) { + return Mono.just(new CustomResult("Processed: " + input)); + } + + } + + NoOutputSchemaTool toolObject = new NoOutputSchemaTool(); + AsyncMcpToolProvider provider = new AsyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("no-output-schema-tool"); + // Output schema should not be generated when disabled + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + @Test void testGetToolSpecificationsWithOutputSchemaGeneration() { // Helper class for complex return type diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProviderTests.java new file mode 100644 index 0000000..aad541b --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/AsyncStatelessMcpToolProviderTests.java @@ -0,0 +1,1010 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springaicommunity.mcp.provider.tool; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpTool; + +import io.modelcontextprotocol.server.McpStatelessServerFeatures.AsyncToolSpecification; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.ToolAnnotations; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests for {@link AsyncStatelessMcpToolProvider}. + * + * @author Christian Tzolov + */ +public class AsyncStatelessMcpToolProviderTests { + + @Test + void testConstructorWithNullToolObjects() { + assertThatThrownBy(() -> new AsyncStatelessMcpToolProvider(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toolObjects cannot be null"); + } + + @Test + void testGetToolSpecificationsWithSingleValidTool() { + // Create a class with only one valid async tool method + class SingleValidTool { + + @McpTool(name = "test-tool", description = "A test tool") + public Mono testTool(String input) { + return Mono.just("Processed: " + input); + } + + } + + SingleValidTool toolObject = new SingleValidTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).isNotNull(); + assertThat(toolSpecs).hasSize(1); + + AsyncToolSpecification toolSpec = toolSpecs.get(0); + assertThat(toolSpec.tool().name()).isEqualTo("test-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("A test tool"); + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + assertThat(toolSpec.callHandler()).isNotNull(); + + // Test that the handler works with McpTransportContext + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("test-tool", Map.of("input", "hello")); + Mono result = toolSpec.callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("Processed: hello"); + }).verifyComplete(); + } + + @Test + void testGetToolSpecificationsWithCustomToolName() { + class CustomNameTool { + + @McpTool(name = "custom-name", description = "Custom named tool") + public Mono methodWithDifferentName(String input) { + return Mono.just("Custom: " + input); + } + + } + + CustomNameTool toolObject = new CustomNameTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("custom-name"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Custom named tool"); + } + + @Test + void testGetToolSpecificationsWithDefaultToolName() { + class DefaultNameTool { + + @McpTool(description = "Tool with default name") + public Mono defaultNameMethod(String input) { + return Mono.just("Default: " + input); + } + + } + + DefaultNameTool toolObject = new DefaultNameTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("defaultNameMethod"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool with default name"); + } + + @Test + void testGetToolSpecificationsWithEmptyToolName() { + class EmptyNameTool { + + @McpTool(name = "", description = "Tool with empty name") + public Mono emptyNameMethod(String input) { + return Mono.just("Empty: " + input); + } + + } + + EmptyNameTool toolObject = new EmptyNameTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("emptyNameMethod"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool with empty name"); + } + + @Test + void testGetToolSpecificationsFiltersOutSyncReturnTypes() { + class MixedReturnTool { + + @McpTool(name = "sync-tool", description = "Synchronous tool") + public String syncTool(String input) { + return "Sync: " + input; + } + + @McpTool(name = "async-tool", description = "Asynchronous tool") + public Mono asyncTool(String input) { + return Mono.just("Async: " + input); + } + + } + + MixedReturnTool toolObject = new MixedReturnTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("async-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Asynchronous tool"); + } + + @Test + void testGetToolSpecificationsWithFluxReturnType() { + class FluxReturnTool { + + @McpTool(name = "flux-tool", description = "Tool returning Flux") + public Flux fluxTool(String input) { + return Flux.just("First: " + input, "Second: " + input); + } + + @McpTool(name = "mono-tool", description = "Tool returning Mono") + public Mono monoTool(String input) { + return Mono.just("Mono: " + input); + } + + } + + FluxReturnTool toolObject = new FluxReturnTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(2); + assertThat(toolSpecs.get(0).tool().name()).isIn("flux-tool", "mono-tool"); + assertThat(toolSpecs.get(1).tool().name()).isIn("flux-tool", "mono-tool"); + assertThat(toolSpecs.get(0).tool().name()).isNotEqualTo(toolSpecs.get(1).tool().name()); + } + + @Test + void testGetToolSpecificationsWithMultipleToolMethods() { + class MultipleToolMethods { + + @McpTool(name = "tool1", description = "First tool") + public Mono firstTool(String input) { + return Mono.just("First: " + input); + } + + @McpTool(name = "tool2", description = "Second tool") + public Mono secondTool(String input) { + return Mono.just("Second: " + input); + } + + } + + MultipleToolMethods toolObject = new MultipleToolMethods(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(2); + assertThat(toolSpecs.get(0).tool().name()).isIn("tool1", "tool2"); + assertThat(toolSpecs.get(1).tool().name()).isIn("tool1", "tool2"); + assertThat(toolSpecs.get(0).tool().name()).isNotEqualTo(toolSpecs.get(1).tool().name()); + } + + @Test + void testGetToolSpecificationsWithMultipleToolObjects() { + class FirstToolObject { + + @McpTool(name = "first-tool", description = "First tool") + public Mono firstTool(String input) { + return Mono.just("First: " + input); + } + + } + + class SecondToolObject { + + @McpTool(name = "second-tool", description = "Second tool") + public Mono secondTool(String input) { + return Mono.just("Second: " + input); + } + + } + + FirstToolObject firstObject = new FirstToolObject(); + SecondToolObject secondObject = new SecondToolObject(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(firstObject, secondObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(2); + assertThat(toolSpecs.get(0).tool().name()).isIn("first-tool", "second-tool"); + assertThat(toolSpecs.get(1).tool().name()).isIn("first-tool", "second-tool"); + assertThat(toolSpecs.get(0).tool().name()).isNotEqualTo(toolSpecs.get(1).tool().name()); + } + + @Test + void testGetToolSpecificationsWithMixedMethods() { + class MixedMethods { + + @McpTool(name = "valid-tool", description = "Valid async tool") + public Mono validTool(String input) { + return Mono.just("Valid: " + input); + } + + public String nonAnnotatedMethod(String input) { + return "Non-annotated: " + input; + } + + @McpTool(name = "sync-tool", description = "Sync tool") + public String syncTool(String input) { + return "Sync: " + input; + } + + } + + MixedMethods toolObject = new MixedMethods(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("valid-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Valid async tool"); + } + + @Test + void testGetToolSpecificationsWithComplexParameters() { + class ComplexParameterTool { + + @McpTool(name = "complex-tool", description = "Tool with complex parameters") + public Mono complexTool(String name, int age, boolean active, List tags) { + return Mono.just(String.format("Name: %s, Age: %d, Active: %b, Tags: %s", name, age, active, + String.join(",", tags))); + } + + } + + ComplexParameterTool toolObject = new ComplexParameterTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("complex-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool with complex parameters"); + assertThat(toolSpecs.get(0).tool().inputSchema()).isNotNull(); + + // Test that the handler works with complex parameters + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("complex-tool", + Map.of("name", "John", "age", 30, "active", true, "tags", List.of("tag1", "tag2"))); + Mono result = toolSpecs.get(0).callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()) + .isEqualTo("Name: John, Age: 30, Active: true, Tags: tag1,tag2"); + }).verifyComplete(); + } + + @Test + void testGetToolSpecificationsWithNoParameters() { + class NoParameterTool { + + @McpTool(name = "no-param-tool", description = "Tool with no parameters") + public Mono noParamTool() { + return Mono.just("No parameters needed"); + } + + } + + NoParameterTool toolObject = new NoParameterTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("no-param-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool with no parameters"); + + // Test that the handler works with no parameters + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("no-param-tool", Map.of()); + Mono result = toolSpecs.get(0).callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("No parameters needed"); + }).verifyComplete(); + } + + @Test + void testGetToolSpecificationsWithCallToolResultReturn() { + class CallToolResultTool { + + @McpTool(name = "result-tool", description = "Tool returning Mono") + public Mono resultTool(String message) { + return Mono.just(CallToolResult.builder().addTextContent("Result: " + message).build()); + } + + } + + CallToolResultTool toolObject = new CallToolResultTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("result-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool returning Mono"); + + // Test that the handler works with Mono return type + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("result-tool", Map.of("message", "test")); + Mono result = toolSpecs.get(0).callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("Result: test"); + }).verifyComplete(); + } + + @Test + void testGetToolSpecificationsWithMonoVoidReturn() { + class MonoVoidTool { + + @McpTool(name = "void-tool", description = "Tool returning Mono") + public Mono voidTool(String input) { + // Simulate some side effect + System.out.println("Processing: " + input); + return Mono.empty(); + } + + } + + MonoVoidTool toolObject = new MonoVoidTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("void-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool returning Mono"); + + // Test that the handler works with Mono return type + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("void-tool", Map.of("input", "test")); + Mono result = toolSpecs.get(0).callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + // For Mono, the framework returns a "Done" message + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("\"Done\""); + }).verifyComplete(); + } + + @Test + void testGetToolSpecificationsWithPrivateMethod() { + class PrivateMethodTool { + + @McpTool(name = "private-tool", description = "Private tool method") + private Mono privateTool(String input) { + return Mono.just("Private: " + input); + } + + } + + PrivateMethodTool toolObject = new PrivateMethodTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("private-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Private tool method"); + + // Test that the handler works with private methods + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("private-tool", Map.of("input", "test")); + Mono result = toolSpecs.get(0).callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("Private: test"); + }).verifyComplete(); + } + + @Test + void testGetToolSpecificationsJsonSchemaGeneration() { + class SchemaTestTool { + + @McpTool(name = "schema-tool", description = "Tool for schema testing") + public Mono schemaTool(String requiredParam, Integer optionalParam) { + return Mono.just("Schema test: " + requiredParam + ", " + optionalParam); + } + + } + + SchemaTestTool toolObject = new SchemaTestTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("schema-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool for schema testing"); + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + + // The input schema should be a valid JSON string containing parameter names + String schemaString = toolSpec.tool().inputSchema().toString(); + assertThat(schemaString).isNotEmpty(); + assertThat(schemaString).contains("requiredParam"); + assertThat(schemaString).contains("optionalParam"); + } + + @Test + void testGetToolSpecificationsWithFluxHandling() { + class FluxHandlingTool { + + @McpTool(name = "flux-handling-tool", description = "Tool that handles Flux properly") + public Flux fluxHandlingTool(String input) { + return Flux.just("Item1: " + input, "Item2: " + input, "Item3: " + input); + } + + } + + FluxHandlingTool toolObject = new FluxHandlingTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("flux-handling-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool that handles Flux properly"); + + // Test that the handler works with Flux return type + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("flux-handling-tool", Map.of("input", "test")); + Mono result = toolSpecs.get(0).callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + // Flux results are typically concatenated or collected into a single response + String content = ((TextContent) callToolResult.content().get(0)).text(); + assertThat(content).contains("test"); + }).verifyComplete(); + } + + @Test + void testToolWithTitle() { + class TitleTool { + + @McpTool(name = "title-tool", description = "Tool with title", title = "Custom Title") + public Mono titleTool(String input) { + return Mono.just("Title: " + input); + } + + } + + TitleTool toolObject = new TitleTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("title-tool"); + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Custom Title"); + } + + @Test + void testToolTitlePrecedence() { + // Test that title attribute takes precedence over annotations.title + class TitlePrecedenceTool { + + @McpTool(name = "precedence-tool", description = "Tool with title precedence", title = "Title Attribute", + annotations = @McpTool.McpAnnotations(title = "Annotations Title")) + public Mono precedenceTool(String input) { + return Mono.just("Precedence: " + input); + } + + } + + TitlePrecedenceTool toolObject = new TitlePrecedenceTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // According to the implementation, title attribute takes precedence over + // annotations.title + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Title Attribute"); + } + + @Test + void testToolAnnotationsTitleUsedWhenNoTitleAttribute() { + // Test that annotations.title is used when title attribute is not provided + class AnnotationsTitleTool { + + @McpTool(name = "annotations-title-tool", description = "Tool with only annotations title", + annotations = @McpTool.McpAnnotations(title = "Annotations Title Only")) + public Mono annotationsTitleTool(String input) { + return Mono.just("Annotations title: " + input); + } + + } + + AnnotationsTitleTool toolObject = new AnnotationsTitleTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // When no title attribute is provided, annotations.title should be used + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Annotations Title Only"); + } + + @Test + void testToolWithoutTitleUsesName() { + class NoTitleTool { + + @McpTool(name = "no-title-tool", description = "Tool without title") + public Mono noTitleTool(String input) { + return Mono.just("No title: " + input); + } + + } + + NoTitleTool toolObject = new NoTitleTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // When no title is provided, the name should be used + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("no-title-tool"); + } + + @Test + void testToolWithAnnotations() { + class AnnotatedTool { + + @McpTool(name = "annotated-tool", description = "Tool with annotations", + annotations = @McpTool.McpAnnotations(title = "Annotated Tool", readOnlyHint = true, + destructiveHint = false, idempotentHint = true, openWorldHint = false)) + public Mono annotatedTool(String input) { + return Mono.just("Annotated: " + input); + } + + } + + AnnotatedTool toolObject = new AnnotatedTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("annotated-tool"); + assertThat(toolSpec.tool().title()).isEqualTo("Annotated Tool"); + + ToolAnnotations annotations = toolSpec.tool().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.title()).isEqualTo("Annotated Tool"); + assertThat(annotations.readOnlyHint()).isTrue(); + assertThat(annotations.destructiveHint()).isFalse(); + assertThat(annotations.idempotentHint()).isTrue(); + assertThat(annotations.openWorldHint()).isFalse(); + } + + @Test + void testToolWithDefaultAnnotations() { + class DefaultAnnotationsTool { + + @McpTool(name = "default-annotations-tool", description = "Tool with default annotations") + public Mono defaultAnnotationsTool(String input) { + return Mono.just("Default annotations: " + input); + } + + } + + DefaultAnnotationsTool toolObject = new DefaultAnnotationsTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + // With default annotations, the annotations object should still be created + ToolAnnotations annotations = toolSpec.tool().annotations(); + assertThat(annotations).isNotNull(); + // Check default values + assertThat(annotations.readOnlyHint()).isFalse(); + assertThat(annotations.destructiveHint()).isTrue(); + assertThat(annotations.idempotentHint()).isFalse(); + assertThat(annotations.openWorldHint()).isTrue(); + } + + @Test + void testToolWithCallToolRequestParameter() { + class CallToolRequestParamTool { + + @McpTool(name = "request-param-tool", description = "Tool with CallToolRequest parameter") + public Mono requestParamTool(CallToolRequest request, String additionalParam) { + return Mono.just("Request tool: " + request.name() + ", param: " + additionalParam); + } + + } + + CallToolRequestParamTool toolObject = new CallToolRequestParamTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("request-param-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with CallToolRequest parameter"); + + // The input schema should still be generated but should handle CallToolRequest + // specially + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + String schemaString = toolSpec.tool().inputSchema().toString(); + // Should contain the additional parameter but not the CallToolRequest + assertThat(schemaString).contains("additionalParam"); + } + + @Test + void testToolWithOnlyCallToolRequestParameter() { + class OnlyCallToolRequestTool { + + @McpTool(name = "only-request-tool", description = "Tool with only CallToolRequest parameter") + public Mono onlyRequestTool(CallToolRequest request) { + return Mono.just("Only request tool: " + request.name()); + } + + } + + OnlyCallToolRequestTool toolObject = new OnlyCallToolRequestTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("only-request-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with only CallToolRequest parameter"); + + // The input schema should be minimal when only CallToolRequest is present + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + } + + @Test + void testToolWithMcpTransportContextParameter() { + class TransportContextParamTool { + + @McpTool(name = "context-param-tool", description = "Tool with McpTransportContext parameter") + public Mono contextParamTool(McpTransportContext context, String additionalParam) { + return Mono.just("Context tool with param: " + additionalParam); + } + + } + + TransportContextParamTool toolObject = new TransportContextParamTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("context-param-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with McpTransportContext parameter"); + + // The input schema should handle McpTransportContext specially + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + String schemaString = toolSpec.tool().inputSchema().toString(); + // Should contain the additional parameter but not the McpTransportContext + assertThat(schemaString).contains("additionalParam"); + + // Test that the handler works with McpTransportContext parameter + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("context-param-tool", Map.of("additionalParam", "test")); + Mono result = toolSpec.callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()) + .isEqualTo("Context tool with param: test"); + }).verifyComplete(); + } + + @Test + void testToolWithOnlyMcpTransportContextParameter() { + class OnlyTransportContextTool { + + @McpTool(name = "only-context-tool", description = "Tool with only McpTransportContext parameter") + public Mono onlyContextTool(McpTransportContext context) { + return Mono.just("Only context tool executed"); + } + + } + + OnlyTransportContextTool toolObject = new OnlyTransportContextTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("only-context-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with only McpTransportContext parameter"); + + // The input schema should be minimal when only McpTransportContext is present + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + + // Test that the handler works + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("only-context-tool", Map.of()); + Mono result = toolSpec.callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("Only context tool executed"); + }).verifyComplete(); + } + + @Test + void testToolWithVoidReturnType() { + class VoidTool { + + @McpTool(name = "void-tool", description = "Tool with void return") + public Mono voidTool(String input) { + // Simulate some side effect + System.out.println("Processing: " + input); + return Mono.empty(); + } + + } + + VoidTool toolObject = new VoidTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("void-tool"); + // Output schema should not be generated for void return type + assertThat(toolSpec.tool().outputSchema()).isNull(); + + // Test that the handler works with Mono return type + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("void-tool", Map.of("input", "test")); + Mono result = toolSpec.callHandler().apply(context, request); + + StepVerifier.create(result).assertNext(callToolResult -> { + assertThat(callToolResult).isNotNull(); + assertThat(callToolResult.isError()).isFalse(); + // For Mono, the framework returns a "Done" message + assertThat(callToolResult.content()).hasSize(1); + assertThat(callToolResult.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) callToolResult.content().get(0)).text()).isEqualTo("\"Done\""); + }).verifyComplete(); + } + + @Test + void testToolWithPrimitiveReturnTypeNoOutputSchema() { + // Reactive methods can't return primitives directly, but can return wrapped + // primitives + class PrimitiveTool { + + @McpTool(name = "primitive-tool", description = "Tool with primitive return") + public Mono primitiveTool(String input) { + return Mono.just(input.length()); + } + + } + + PrimitiveTool toolObject = new PrimitiveTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("primitive-tool"); + // Output schema should not be generated for primitive wrapper types + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithStringReturnTypeNoOutputSchema() { + class StringTool { + + @McpTool(name = "string-tool", description = "Tool with String return") + public Mono stringTool(String input) { + return Mono.just("Result: " + input); + } + + } + + StringTool toolObject = new StringTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("string-tool"); + // Output schema should not be generated for simple value types like String + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithDisabledOutputSchemaGeneration() { + class CustomResult { + + public String message; + + public CustomResult(String message) { + this.message = message; + } + + } + + class NoOutputSchemaTool { + + @McpTool(name = "no-output-schema-tool", description = "Tool without output schema", + generateOutputSchema = false) + public Mono noOutputSchemaTool(String input) { + return Mono.just(new CustomResult("Processed: " + input)); + } + + } + + NoOutputSchemaTool toolObject = new NoOutputSchemaTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("no-output-schema-tool"); + // Output schema should not be generated when disabled + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testGetToolSpecificationsWithOutputSchemaGeneration() { + // Helper class for complex return type + class ComplexResult { + + private final String message; + + private final int count; + + private final boolean success; + + public ComplexResult(String message, int count, boolean success) { + this.message = message; + this.count = count; + this.success = success; + } + + public String getMessage() { + return message; + } + + public int getCount() { + return count; + } + + public boolean isSuccess() { + return success; + } + + } + + class OutputSchemaTestTool { + + @McpTool(name = "output-schema-tool", description = "Tool for output schema testing", + generateOutputSchema = true) + public Mono outputSchemaTool(String input) { + return Mono.just(new ComplexResult(input, 42, true)); + } + + } + + OutputSchemaTestTool toolObject = new OutputSchemaTestTool(); + AsyncStatelessMcpToolProvider provider = new AsyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + AsyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("output-schema-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool for output schema testing"); + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + // Output schema should be generated for complex return types + assertThat(toolSpec.tool().outputSchema()).isNotNull(); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProviderTests.java index 83e784e..82bebb2 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProviderTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/SyncMcpToolProviderTests.java @@ -31,6 +31,7 @@ import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.ToolAnnotations; import reactor.core.publisher.Mono; /** @@ -424,4 +425,360 @@ public String schemaTool(String requiredParam, Integer optionalParam) { assertThat(schemaString).contains("optionalParam"); } + @Test + void testToolWithTitle() { + class TitleTool { + + @McpTool(name = "title-tool", description = "Tool with title", title = "Custom Title") + public String titleTool(String input) { + return "Title: " + input; + } + + } + + TitleTool toolObject = new TitleTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("title-tool"); + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Custom Title"); + } + + @Test + void testToolTitlePrecedence() { + // Test that title attribute takes precedence over annotations.title + class TitlePrecedenceTool { + + @McpTool(name = "precedence-tool", description = "Tool with title precedence", title = "Title Attribute", + annotations = @McpTool.McpAnnotations(title = "Annotations Title")) + public String precedenceTool(String input) { + return "Precedence: " + input; + } + + } + + TitlePrecedenceTool toolObject = new TitlePrecedenceTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // According to the implementation, title attribute takes precedence over + // annotations.title + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Title Attribute"); + } + + @Test + void testToolAnnotationsTitleUsedWhenNoTitleAttribute() { + // Test that annotations.title is used when title attribute is not provided + class AnnotationsTitleTool { + + @McpTool(name = "annotations-title-tool", description = "Tool with only annotations title", + annotations = @McpTool.McpAnnotations(title = "Annotations Title Only")) + public String annotationsTitleTool(String input) { + return "Annotations title: " + input; + } + + } + + AnnotationsTitleTool toolObject = new AnnotationsTitleTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // When no title attribute is provided, annotations.title should be used + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Annotations Title Only"); + } + + @Test + void testToolWithoutTitleUsesName() { + class NoTitleTool { + + @McpTool(name = "no-title-tool", description = "Tool without title") + public String noTitleTool(String input) { + return "No title: " + input; + } + + } + + NoTitleTool toolObject = new NoTitleTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // When no title is provided, the name should be used + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("no-title-tool"); + } + + @Test + void testToolWithAnnotations() { + class AnnotatedTool { + + @McpTool(name = "annotated-tool", description = "Tool with annotations", + annotations = @McpTool.McpAnnotations(title = "Annotated Tool", readOnlyHint = true, + destructiveHint = false, idempotentHint = true, openWorldHint = false)) + public String annotatedTool(String input) { + return "Annotated: " + input; + } + + } + + AnnotatedTool toolObject = new AnnotatedTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("annotated-tool"); + assertThat(toolSpec.tool().title()).isEqualTo("Annotated Tool"); + + ToolAnnotations annotations = toolSpec.tool().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.title()).isEqualTo("Annotated Tool"); + assertThat(annotations.readOnlyHint()).isTrue(); + assertThat(annotations.destructiveHint()).isFalse(); + assertThat(annotations.idempotentHint()).isTrue(); + assertThat(annotations.openWorldHint()).isFalse(); + } + + @Test + void testToolWithDefaultAnnotations() { + class DefaultAnnotationsTool { + + @McpTool(name = "default-annotations-tool", description = "Tool with default annotations") + public String defaultAnnotationsTool(String input) { + return "Default annotations: " + input; + } + + } + + DefaultAnnotationsTool toolObject = new DefaultAnnotationsTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + // With default annotations, the annotations object should still be created + ToolAnnotations annotations = toolSpec.tool().annotations(); + assertThat(annotations).isNotNull(); + // Check default values + assertThat(annotations.readOnlyHint()).isFalse(); + assertThat(annotations.destructiveHint()).isTrue(); + assertThat(annotations.idempotentHint()).isFalse(); + assertThat(annotations.openWorldHint()).isTrue(); + } + + @Test + void testToolWithOutputSchemaGeneration() { + // Define a custom result class + class CustomResult { + + public String message; + + public int count; + + public CustomResult(String message, int count) { + this.message = message; + this.count = count; + } + + } + + class OutputSchemaTool { + + @McpTool(name = "output-schema-tool", description = "Tool with output schema") + public CustomResult outputSchemaTool(String input) { + return new CustomResult("Processed: " + input, input.length()); + } + + } + + OutputSchemaTool toolObject = new OutputSchemaTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("output-schema-tool"); + // Output schema should be generated for complex types + assertThat(toolSpec.tool().outputSchema()).isNotNull(); + String outputSchemaString = toolSpec.tool().outputSchema().toString(); + assertThat(outputSchemaString).contains("message"); + assertThat(outputSchemaString).contains("count"); + } + + @Test + void testToolWithDisabledOutputSchemaGeneration() { + class CustomResult { + + public String message; + + public CustomResult(String message) { + this.message = message; + } + + } + + class NoOutputSchemaTool { + + @McpTool(name = "no-output-schema-tool", description = "Tool without output schema", + generateOutputSchema = false) + public CustomResult noOutputSchemaTool(String input) { + return new CustomResult("Processed: " + input); + } + + } + + NoOutputSchemaTool toolObject = new NoOutputSchemaTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("no-output-schema-tool"); + // Output schema should not be generated when disabled + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithPrimitiveReturnTypeNoOutputSchema() { + class PrimitiveTool { + + @McpTool(name = "primitive-tool", description = "Tool with primitive return") + public int primitiveTool(String input) { + return input.length(); + } + + } + + PrimitiveTool toolObject = new PrimitiveTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("primitive-tool"); + // Output schema should not be generated for primitive types + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithVoidReturnTypeNoOutputSchema() { + class VoidTool { + + @McpTool(name = "void-tool", description = "Tool with void return") + public void voidTool(String input) { + // Do nothing + } + + } + + VoidTool toolObject = new VoidTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("void-tool"); + // Output schema should not be generated for void return type + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithStringReturnTypeNoOutputSchema() { + class StringTool { + + @McpTool(name = "string-tool", description = "Tool with String return") + public String stringTool(String input) { + return "Result: " + input; + } + + } + + StringTool toolObject = new StringTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("string-tool"); + // Output schema should not be generated for simple value types like String + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithCallToolRequestParameter() { + class CallToolRequestParamTool { + + @McpTool(name = "request-param-tool", description = "Tool with CallToolRequest parameter") + public String requestParamTool(CallToolRequest request, String additionalParam) { + return "Request tool: " + request.name() + ", param: " + additionalParam; + } + + } + + CallToolRequestParamTool toolObject = new CallToolRequestParamTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("request-param-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with CallToolRequest parameter"); + + // The input schema should still be generated but should handle CallToolRequest + // specially + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + String schemaString = toolSpec.tool().inputSchema().toString(); + // Should contain the additional parameter but not the CallToolRequest + assertThat(schemaString).contains("additionalParam"); + } + + @Test + void testToolWithOnlyCallToolRequestParameter() { + + class OnlyCallToolRequestTool { + + @McpTool(name = "only-request-tool", description = "Tool with only CallToolRequest parameter") + public String onlyRequestTool(CallToolRequest request) { + return "Only request tool: " + request.name(); + } + + } + + OnlyCallToolRequestTool toolObject = new OnlyCallToolRequestTool(); + SyncMcpToolProvider provider = new SyncMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("only-request-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with only CallToolRequest parameter"); + + // The input schema should be minimal when only CallToolRequest is present + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProviderTests.java new file mode 100644 index 0000000..0cdff12 --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/tool/SyncStatelessMcpToolProviderTests.java @@ -0,0 +1,861 @@ +/* + * Copyright 2025-2025 the original author or authors. + * + * Licensed 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 + * + * https://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.springaicommunity.mcp.provider.tool; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpTool; + +import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import io.modelcontextprotocol.spec.McpSchema.ToolAnnotations; +import reactor.core.publisher.Mono; + +/** + * Tests for {@link SyncStatelessMcpToolProvider}. + * + * @author Christian Tzolov + */ +public class SyncStatelessMcpToolProviderTests { + + @Test + void testConstructorWithNullToolObjects() { + assertThatThrownBy(() -> new SyncStatelessMcpToolProvider(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("toolObjects cannot be null"); + } + + @Test + void testGetToolSpecificationsWithSingleValidTool() { + // Create a class with only one valid tool method + class SingleValidTool { + + @McpTool(name = "test-tool", description = "A test tool") + public String testTool(String input) { + return "Processed: " + input; + } + + } + + SingleValidTool toolObject = new SingleValidTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).isNotNull(); + assertThat(toolSpecs).hasSize(1); + + SyncToolSpecification toolSpec = toolSpecs.get(0); + assertThat(toolSpec.tool().name()).isEqualTo("test-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("A test tool"); + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + assertThat(toolSpec.callHandler()).isNotNull(); + + // Test that the handler works with McpTransportContext + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("test-tool", Map.of("input", "hello")); + CallToolResult result = toolSpec.callHandler().apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Processed: hello"); + } + + @Test + void testGetToolSpecificationsWithCustomToolName() { + class CustomNameTool { + + @McpTool(name = "custom-name", description = "Custom named tool") + public String methodWithDifferentName(String input) { + return "Custom: " + input; + } + + } + + CustomNameTool toolObject = new CustomNameTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("custom-name"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Custom named tool"); + } + + @Test + void testGetToolSpecificationsWithDefaultToolName() { + class DefaultNameTool { + + @McpTool(description = "Tool with default name") + public String defaultNameMethod(String input) { + return "Default: " + input; + } + + } + + DefaultNameTool toolObject = new DefaultNameTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("defaultNameMethod"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool with default name"); + } + + @Test + void testGetToolSpecificationsWithEmptyToolName() { + class EmptyNameTool { + + @McpTool(name = "", description = "Tool with empty name") + public String emptyNameMethod(String input) { + return "Empty: " + input; + } + + } + + EmptyNameTool toolObject = new EmptyNameTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("emptyNameMethod"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool with empty name"); + } + + @Test + void testGetToolSpecificationsFiltersOutMonoReturnTypes() { + class MonoReturnTool { + + @McpTool(name = "mono-tool", description = "Tool returning Mono") + public Mono monoTool(String input) { + return Mono.just("Mono: " + input); + } + + @McpTool(name = "sync-tool", description = "Synchronous tool") + public String syncTool(String input) { + return "Sync: " + input; + } + + } + + MonoReturnTool toolObject = new MonoReturnTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("sync-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Synchronous tool"); + } + + @Test + void testGetToolSpecificationsWithMultipleToolMethods() { + class MultipleToolMethods { + + @McpTool(name = "tool1", description = "First tool") + public String firstTool(String input) { + return "First: " + input; + } + + @McpTool(name = "tool2", description = "Second tool") + public String secondTool(String input) { + return "Second: " + input; + } + + } + + MultipleToolMethods toolObject = new MultipleToolMethods(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(2); + assertThat(toolSpecs.get(0).tool().name()).isIn("tool1", "tool2"); + assertThat(toolSpecs.get(1).tool().name()).isIn("tool1", "tool2"); + assertThat(toolSpecs.get(0).tool().name()).isNotEqualTo(toolSpecs.get(1).tool().name()); + } + + @Test + void testGetToolSpecificationsWithMultipleToolObjects() { + class FirstToolObject { + + @McpTool(name = "first-tool", description = "First tool") + public String firstTool(String input) { + return "First: " + input; + } + + } + + class SecondToolObject { + + @McpTool(name = "second-tool", description = "Second tool") + public String secondTool(String input) { + return "Second: " + input; + } + + } + + FirstToolObject firstObject = new FirstToolObject(); + SecondToolObject secondObject = new SecondToolObject(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(firstObject, secondObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(2); + assertThat(toolSpecs.get(0).tool().name()).isIn("first-tool", "second-tool"); + assertThat(toolSpecs.get(1).tool().name()).isIn("first-tool", "second-tool"); + assertThat(toolSpecs.get(0).tool().name()).isNotEqualTo(toolSpecs.get(1).tool().name()); + } + + @Test + void testGetToolSpecificationsWithMixedMethods() { + class MixedMethods { + + @McpTool(name = "valid-tool", description = "Valid tool") + public String validTool(String input) { + return "Valid: " + input; + } + + public String nonAnnotatedMethod(String input) { + return "Non-annotated: " + input; + } + + @McpTool(name = "mono-tool", description = "Mono tool") + public Mono monoTool(String input) { + return Mono.just("Mono: " + input); + } + + } + + MixedMethods toolObject = new MixedMethods(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("valid-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Valid tool"); + } + + @Test + void testGetToolSpecificationsWithComplexParameters() { + class ComplexParameterTool { + + @McpTool(name = "complex-tool", description = "Tool with complex parameters") + public String complexTool(String name, int age, boolean active, List tags) { + return String.format("Name: %s, Age: %d, Active: %b, Tags: %s", name, age, active, + String.join(",", tags)); + } + + } + + ComplexParameterTool toolObject = new ComplexParameterTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("complex-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool with complex parameters"); + assertThat(toolSpecs.get(0).tool().inputSchema()).isNotNull(); + + // Test that the handler works with complex parameters + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("complex-tool", + Map.of("name", "John", "age", 30, "active", true, "tags", List.of("tag1", "tag2"))); + CallToolResult result = toolSpecs.get(0).callHandler().apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()) + .isEqualTo("Name: John, Age: 30, Active: true, Tags: tag1,tag2"); + } + + @Test + void testGetToolSpecificationsWithNoParameters() { + class NoParameterTool { + + @McpTool(name = "no-param-tool", description = "Tool with no parameters") + public String noParamTool() { + return "No parameters needed"; + } + + } + + NoParameterTool toolObject = new NoParameterTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("no-param-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool with no parameters"); + + // Test that the handler works with no parameters + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("no-param-tool", Map.of()); + CallToolResult result = toolSpecs.get(0).callHandler().apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("No parameters needed"); + } + + @Test + void testGetToolSpecificationsWithCallToolResultReturn() { + class CallToolResultTool { + + @McpTool(name = "result-tool", description = "Tool returning CallToolResult") + public CallToolResult resultTool(String message) { + return CallToolResult.builder().addTextContent("Result: " + message).build(); + } + + } + + CallToolResultTool toolObject = new CallToolResultTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("result-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Tool returning CallToolResult"); + + // Test that the handler works with CallToolResult return type + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("result-tool", Map.of("message", "test")); + CallToolResult result = toolSpecs.get(0).callHandler().apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Result: test"); + } + + @Test + void testGetToolSpecificationsWithPrivateMethod() { + class PrivateMethodTool { + + @McpTool(name = "private-tool", description = "Private tool method") + private String privateTool(String input) { + return "Private: " + input; + } + + } + + PrivateMethodTool toolObject = new PrivateMethodTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("private-tool"); + assertThat(toolSpecs.get(0).tool().description()).isEqualTo("Private tool method"); + + // Test that the handler works with private methods + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("private-tool", Map.of("input", "test")); + CallToolResult result = toolSpecs.get(0).callHandler().apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Private: test"); + } + + @Test + void testGetToolSpecificationsJsonSchemaGeneration() { + class SchemaTestTool { + + @McpTool(name = "schema-tool", description = "Tool for schema testing") + public String schemaTool(String requiredParam, Integer optionalParam) { + return "Schema test: " + requiredParam + ", " + optionalParam; + } + + } + + SchemaTestTool toolObject = new SchemaTestTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("schema-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool for schema testing"); + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + + // The input schema should be a valid JSON string containing parameter names + String schemaString = toolSpec.tool().inputSchema().toString(); + assertThat(schemaString).isNotEmpty(); + assertThat(schemaString).contains("requiredParam"); + assertThat(schemaString).contains("optionalParam"); + } + + @Test + void testToolWithTitle() { + class TitleTool { + + @McpTool(name = "title-tool", description = "Tool with title", title = "Custom Title") + public String titleTool(String input) { + return "Title: " + input; + } + + } + + TitleTool toolObject = new TitleTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + assertThat(toolSpecs.get(0).tool().name()).isEqualTo("title-tool"); + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Custom Title"); + } + + @Test + void testToolTitlePrecedence() { + // Test that title attribute takes precedence over annotations.title + class TitlePrecedenceTool { + + @McpTool(name = "precedence-tool", description = "Tool with title precedence", title = "Title Attribute", + annotations = @McpTool.McpAnnotations(title = "Annotations Title")) + public String precedenceTool(String input) { + return "Precedence: " + input; + } + + } + + TitlePrecedenceTool toolObject = new TitlePrecedenceTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // According to the implementation, title attribute takes precedence over + // annotations.title + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Title Attribute"); + } + + @Test + void testToolAnnotationsTitleUsedWhenNoTitleAttribute() { + // Test that annotations.title is used when title attribute is not provided + class AnnotationsTitleTool { + + @McpTool(name = "annotations-title-tool", description = "Tool with only annotations title", + annotations = @McpTool.McpAnnotations(title = "Annotations Title Only")) + public String annotationsTitleTool(String input) { + return "Annotations title: " + input; + } + + } + + AnnotationsTitleTool toolObject = new AnnotationsTitleTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // When no title attribute is provided, annotations.title should be used + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("Annotations Title Only"); + } + + @Test + void testToolWithoutTitleUsesName() { + class NoTitleTool { + + @McpTool(name = "no-title-tool", description = "Tool without title") + public String noTitleTool(String input) { + return "No title: " + input; + } + + } + + NoTitleTool toolObject = new NoTitleTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + // When no title is provided, the name should be used + assertThat(toolSpecs.get(0).tool().title()).isEqualTo("no-title-tool"); + } + + @Test + void testToolWithAnnotations() { + class AnnotatedTool { + + @McpTool(name = "annotated-tool", description = "Tool with annotations", + annotations = @McpTool.McpAnnotations(title = "Annotated Tool", readOnlyHint = true, + destructiveHint = false, idempotentHint = true, openWorldHint = false)) + public String annotatedTool(String input) { + return "Annotated: " + input; + } + + } + + AnnotatedTool toolObject = new AnnotatedTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("annotated-tool"); + assertThat(toolSpec.tool().title()).isEqualTo("Annotated Tool"); + + ToolAnnotations annotations = toolSpec.tool().annotations(); + assertThat(annotations).isNotNull(); + assertThat(annotations.title()).isEqualTo("Annotated Tool"); + assertThat(annotations.readOnlyHint()).isTrue(); + assertThat(annotations.destructiveHint()).isFalse(); + assertThat(annotations.idempotentHint()).isTrue(); + assertThat(annotations.openWorldHint()).isFalse(); + } + + @Test + void testToolWithDefaultAnnotations() { + class DefaultAnnotationsTool { + + @McpTool(name = "default-annotations-tool", description = "Tool with default annotations") + public String defaultAnnotationsTool(String input) { + return "Default annotations: " + input; + } + + } + + DefaultAnnotationsTool toolObject = new DefaultAnnotationsTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + // With default annotations, the annotations object should still be created + ToolAnnotations annotations = toolSpec.tool().annotations(); + assertThat(annotations).isNotNull(); + // Check default values + assertThat(annotations.readOnlyHint()).isFalse(); + assertThat(annotations.destructiveHint()).isTrue(); + assertThat(annotations.idempotentHint()).isFalse(); + assertThat(annotations.openWorldHint()).isTrue(); + } + + @Test + void testToolWithOutputSchemaGeneration() { + // Define a custom result class + class CustomResult { + + public String message; + + public int count; + + public CustomResult(String message, int count) { + this.message = message; + this.count = count; + } + + } + + class OutputSchemaTool { + + @McpTool(name = "output-schema-tool", description = "Tool with output schema") + public CustomResult outputSchemaTool(String input) { + return new CustomResult("Processed: " + input, input.length()); + } + + } + + OutputSchemaTool toolObject = new OutputSchemaTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("output-schema-tool"); + // Output schema should be generated for complex types + assertThat(toolSpec.tool().outputSchema()).isNotNull(); + String outputSchemaString = toolSpec.tool().outputSchema().toString(); + assertThat(outputSchemaString).contains("message"); + assertThat(outputSchemaString).contains("count"); + } + + @Test + void testToolWithDisabledOutputSchemaGeneration() { + class CustomResult { + + public String message; + + public CustomResult(String message) { + this.message = message; + } + + } + + class NoOutputSchemaTool { + + @McpTool(name = "no-output-schema-tool", description = "Tool without output schema", + generateOutputSchema = false) + public CustomResult noOutputSchemaTool(String input) { + return new CustomResult("Processed: " + input); + } + + } + + NoOutputSchemaTool toolObject = new NoOutputSchemaTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("no-output-schema-tool"); + // Output schema should not be generated when disabled + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithPrimitiveReturnTypeNoOutputSchema() { + class PrimitiveTool { + + @McpTool(name = "primitive-tool", description = "Tool with primitive return") + public int primitiveTool(String input) { + return input.length(); + } + + } + + PrimitiveTool toolObject = new PrimitiveTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("primitive-tool"); + // Output schema should not be generated for primitive types + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithVoidReturnTypeNoOutputSchema() { + class VoidTool { + + @McpTool(name = "void-tool", description = "Tool with void return") + public void voidTool(String input) { + // Do nothing + } + + } + + VoidTool toolObject = new VoidTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("void-tool"); + // Output schema should not be generated for void return type + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithStringReturnTypeNoOutputSchema() { + class StringTool { + + @McpTool(name = "string-tool", description = "Tool with String return") + public String stringTool(String input) { + return "Result: " + input; + } + + } + + StringTool toolObject = new StringTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("string-tool"); + // Output schema should not be generated for simple value types like String + assertThat(toolSpec.tool().outputSchema()).isNull(); + } + + @Test + void testToolWithCallToolRequestParameter() { + class CallToolRequestParamTool { + + @McpTool(name = "request-param-tool", description = "Tool with CallToolRequest parameter") + public String requestParamTool(CallToolRequest request, String additionalParam) { + return "Request tool: " + request.name() + ", param: " + additionalParam; + } + + } + + CallToolRequestParamTool toolObject = new CallToolRequestParamTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("request-param-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with CallToolRequest parameter"); + + // The input schema should still be generated but should handle CallToolRequest + // specially + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + String schemaString = toolSpec.tool().inputSchema().toString(); + // Should contain the additional parameter but not the CallToolRequest + assertThat(schemaString).contains("additionalParam"); + } + + @Test + void testToolWithOnlyCallToolRequestParameter() { + + class OnlyCallToolRequestTool { + + @McpTool(name = "only-request-tool", description = "Tool with only CallToolRequest parameter") + public String onlyRequestTool(CallToolRequest request) { + return "Only request tool: " + request.name(); + } + + } + + OnlyCallToolRequestTool toolObject = new OnlyCallToolRequestTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("only-request-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with only CallToolRequest parameter"); + + // The input schema should be minimal when only CallToolRequest is present + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + } + + @Test + void testToolWithMcpTransportContextParameter() { + class TransportContextParamTool { + + @McpTool(name = "context-param-tool", description = "Tool with McpTransportContext parameter") + public String contextParamTool(McpTransportContext context, String additionalParam) { + return "Context tool with param: " + additionalParam; + } + + } + + TransportContextParamTool toolObject = new TransportContextParamTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("context-param-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with McpTransportContext parameter"); + + // The input schema should handle McpTransportContext specially + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + String schemaString = toolSpec.tool().inputSchema().toString(); + // Should contain the additional parameter but not the McpTransportContext + assertThat(schemaString).contains("additionalParam"); + + // Test that the handler works with McpTransportContext parameter + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("context-param-tool", Map.of("additionalParam", "test")); + CallToolResult result = toolSpec.callHandler().apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Context tool with param: test"); + } + + @Test + void testToolWithOnlyMcpTransportContextParameter() { + class OnlyTransportContextTool { + + @McpTool(name = "only-context-tool", description = "Tool with only McpTransportContext parameter") + public String onlyContextTool(McpTransportContext context) { + return "Only context tool executed"; + } + + } + + OnlyTransportContextTool toolObject = new OnlyTransportContextTool(); + SyncStatelessMcpToolProvider provider = new SyncStatelessMcpToolProvider(List.of(toolObject)); + + List toolSpecs = provider.getToolSpecifications(); + + assertThat(toolSpecs).hasSize(1); + SyncToolSpecification toolSpec = toolSpecs.get(0); + + assertThat(toolSpec.tool().name()).isEqualTo("only-context-tool"); + assertThat(toolSpec.tool().description()).isEqualTo("Tool with only McpTransportContext parameter"); + + // The input schema should be minimal when only McpTransportContext is present + assertThat(toolSpec.tool().inputSchema()).isNotNull(); + + // Test that the handler works + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("only-context-tool", Map.of()); + CallToolResult result = toolSpec.callHandler().apply(context, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0)).isInstanceOf(TextContent.class); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Only context tool executed"); + } + +}