From 1a728da2544d441a52bd80e0553a571b36826b1c Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 22 Aug 2025 12:13:36 +0200 Subject: [PATCH] feat: Add CallToolRequest support for dynamic schema handling - Enable tools to accept CallToolRequest parameters for runtime schema support - Implement injection in all sync/async and stateful/stateless callbacks - Enhance JsonSchemaGenerator for minimal/partial schema generation - Add 82 comprehensive tests across all implementations - Update documentation with examples and usage guidelines Allows tools to process dynamic schemas at runtime while maintaining backward compatibility with existing tool implementations. Signed-off-by: Christian Tzolov --- README.md | 78 ++++ .../AbstractAsyncMcpToolMethodCallback.java | 21 +- .../AbstractSyncMcpToolMethodCallback.java | 21 +- .../tool/AsyncMcpToolMethodCallback.java | 5 +- .../AsyncStatelessMcpToolMethodCallback.java | 2 +- .../tool/SyncMcpToolMethodCallback.java | 5 +- .../SyncStatelessMcpToolMethodCallback.java | 3 +- .../tool/utils/JsonSchemaGenerator.java | 41 +- .../mcp/provider/SyncMcpToolProvider.java | 17 +- .../AsyncCallToolRequestSupportTests.java | 396 ++++++++++++++++ ...ncStatelessMcpToolMethodCallbackTests.java | 79 ++++ .../tool/CallToolRequestSupportTests.java | 433 ++++++++++++++++++ ...ncStatelessMcpToolMethodCallbackTests.java | 77 ++++ 13 files changed, 1168 insertions(+), 10 deletions(-) create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncCallToolRequestSupportTests.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/CallToolRequestSupportTests.java diff --git a/README.md b/README.md index 10c4387..6e4ca58 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,35 @@ public class CalculatorToolProvider { }).subscribeOn(Schedulers.boundedElastic()); } + // Tool with CallToolRequest parameter for dynamic schema support + @McpTool(name = "dynamic-processor", description = "Process data with dynamic schema") + public CallToolResult processDynamic(CallToolRequest request) { + // Access the full request including dynamic schema + Map args = request.arguments(); + + // Process based on runtime schema + String result = "Processed " + args.size() + " arguments dynamically"; + + return CallToolResult.builder() + .addTextContent(result) + .build(); + } + + // Tool with mixed parameters - typed and CallToolRequest + @McpTool(name = "hybrid-processor", description = "Process with both typed and dynamic parameters") + public String processHybrid( + @McpToolParam(description = "Action to perform", required = true) String action, + CallToolRequest request) { + + // Use typed parameter + String actionResult = "Action: " + action; + + // Also access additional dynamic arguments + Map additionalArgs = request.arguments(); + + return actionResult + " with " + (additionalArgs.size() - 1) + " additional parameters"; + } + public static class AreaResult { public double area; public String unit; @@ -433,6 +462,54 @@ public class CalculatorToolProvider { } ``` +#### CallToolRequest Support + +The library supports special `CallToolRequest` parameters in tool methods, enabling dynamic schema handling at runtime. This is useful when you need to: + +- Accept tools with schemas defined at runtime +- Process requests where the input structure isn't known at compile time +- Build flexible tools that adapt to different input schemas + +When a tool method includes a `CallToolRequest` parameter: +- The parameter receives the complete tool request including all arguments +- For methods with only `CallToolRequest`, a minimal schema is generated +- For methods with mixed parameters, only non-`CallToolRequest` parameters are included in the schema +- The `CallToolRequest` parameter is automatically injected and doesn't appear in the tool's input schema + +Example usage: + +```java +// Tool that accepts any schema at runtime +@McpTool(name = "flexible-tool") +public CallToolResult processAnySchema(CallToolRequest request) { + Map args = request.arguments(); + // Process based on whatever schema was provided at runtime + return CallToolResult.success(processedResult); +} + +// Tool with both typed and dynamic parameters +@McpTool(name = "mixed-tool") +public String processMixed( + @McpToolParam("operation") String operation, + @McpToolParam("count") int count, + CallToolRequest request) { + + // Use typed parameters for known fields + String result = operation + " x " + count; + + // Access any additional fields from the request + Map allArgs = request.arguments(); + + return result; +} +``` + +This feature works with all tool callback types: +- `SyncMcpToolMethodCallback` - Synchronous with server exchange +- `AsyncMcpToolMethodCallback` - Asynchronous with server exchange +- `SyncStatelessMcpToolMethodCallback` - Synchronous stateless +- `AsyncStatelessMcpToolMethodCallback` - Asynchronous stateless + ### Async Tool Example ```java @@ -1168,6 +1245,7 @@ public class McpConfig { - **Comprehensive validation** - Ensures method signatures are compatible with MCP operations - **URI template support** - Powerful URI template handling for resource and completion operations - **Tool support with automatic JSON schema generation** - Create MCP tools with automatic input/output schema generation from method signatures +- **Dynamic schema support via CallToolRequest** - Tools can accept `CallToolRequest` parameters to handle dynamic schemas at runtime - **Logging consumer support** - Handle logging message notifications from MCP servers - **Sampling support** - Handle sampling requests from MCP servers - **Spring integration** - Seamless integration with Spring Framework and Spring AI, including support for both stateful and stateless operations diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractAsyncMcpToolMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractAsyncMcpToolMethodCallback.java index 6e5b100..5e32cd8 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractAsyncMcpToolMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractAsyncMcpToolMethodCallback.java @@ -93,12 +93,31 @@ protected Object callMethod(Object[] methodArguments) { * @return An array of method arguments */ protected Object[] buildMethodArguments(T exchangeOrContext, Map toolInputArguments) { + return buildMethodArguments(exchangeOrContext, toolInputArguments, null); + } + + /** + * Builds the method arguments from the context, tool input arguments, and optionally + * the full request. + * @param exchangeOrContext The exchange or context object (e.g., + * McpAsyncServerExchange or McpTransportContext) + * @param toolInputArguments The input arguments from the tool request + * @param request The full CallToolRequest (optional, can be null) + * @return An array of method arguments + */ + protected Object[] buildMethodArguments(T exchangeOrContext, Map toolInputArguments, + CallToolRequest request) { return Stream.of(this.toolMethod.getParameters()).map(parameter -> { - Object rawArgument = toolInputArguments.get(parameter.getName()); + // Check if parameter is CallToolRequest type + if (CallToolRequest.class.isAssignableFrom(parameter.getType())) { + return request; + } if (isExchangeOrContextType(parameter.getType())) { return exchangeOrContext; } + + Object rawArgument = toolInputArguments.get(parameter.getName()); return buildTypedArgument(rawArgument, parameter.getParameterizedType()); }).toArray(); } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractSyncMcpToolMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractSyncMcpToolMethodCallback.java index 6a72b8d..6ca579f 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractSyncMcpToolMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AbstractSyncMcpToolMethodCallback.java @@ -89,12 +89,31 @@ protected Object callMethod(Object[] methodArguments) { * @return An array of method arguments */ protected Object[] buildMethodArguments(T exchangeOrContext, Map toolInputArguments) { + return buildMethodArguments(exchangeOrContext, toolInputArguments, null); + } + + /** + * Builds the method arguments from the context, tool input arguments, and optionally + * the full request. + * @param exchangeOrContext The exchange or context object (e.g., + * McpSyncServerExchange or McpTransportContext) + * @param toolInputArguments The input arguments from the tool request + * @param request The full CallToolRequest (optional, can be null) + * @return An array of method arguments + */ + protected Object[] buildMethodArguments(T exchangeOrContext, Map toolInputArguments, + CallToolRequest request) { return Stream.of(this.toolMethod.getParameters()).map(parameter -> { - Object rawArgument = toolInputArguments.get(parameter.getName()); + // Check if parameter is CallToolRequest type + if (CallToolRequest.class.isAssignableFrom(parameter.getType())) { + return request; + } if (isExchangeOrContextType(parameter.getType())) { return exchangeOrContext; } + + Object rawArgument = toolInputArguments.get(parameter.getName()); return buildTypedArgument(rawArgument, parameter.getParameterizedType()); }).toArray(); } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncMcpToolMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncMcpToolMethodCallback.java index 7433474..0b9e542 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncMcpToolMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncMcpToolMethodCallback.java @@ -60,8 +60,9 @@ public Mono apply(McpAsyncServerExchange exchange, CallToolReque return validateRequest(request).then(Mono.defer(() -> { try { - // Build arguments for the method call - Object[] args = this.buildMethodArguments(exchange, request.arguments()); + // Build arguments for the method call, passing the full request for + // CallToolRequest parameter support + Object[] args = this.buildMethodArguments(exchange, request.arguments(), request); // Invoke the method Object result = this.callMethod(args); diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallback.java index 513a23a..0d51399 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallback.java @@ -62,7 +62,7 @@ public Mono apply(McpTransportContext mcpTransportContext, CallT return validateRequest(request).then(Mono.defer(() -> { try { // Build arguments for the method call - Object[] args = this.buildMethodArguments(mcpTransportContext, request.arguments()); + Object[] args = this.buildMethodArguments(mcpTransportContext, request.arguments(), request); // Invoke the method Object result = this.callMethod(args); diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncMcpToolMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncMcpToolMethodCallback.java index bef5f83..1798da8 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncMcpToolMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncMcpToolMethodCallback.java @@ -58,8 +58,9 @@ public CallToolResult apply(McpSyncServerExchange exchange, CallToolRequest requ validateRequest(request); try { - // Build arguments for the method call - Object[] args = this.buildMethodArguments(exchange, request.arguments()); + // Build arguments for the method call, passing the full request for + // CallToolRequest parameter support + Object[] args = this.buildMethodArguments(exchange, request.arguments(), request); // Invoke the method Object result = this.callMethod(args); diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallback.java index 771e6a7..48862cf 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallback.java @@ -52,7 +52,8 @@ public CallToolResult apply(McpTransportContext mcpTransportContext, CallToolReq try { // Build arguments for the method call - Object[] args = this.buildMethodArguments(mcpTransportContext, callToolRequest.arguments()); + Object[] args = this.buildMethodArguments(mcpTransportContext, callToolRequest.arguments(), + callToolRequest); // Invoke the method Object result = this.callMethod(args); diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/utils/JsonSchemaGenerator.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/utils/JsonSchemaGenerator.java index 2a33dbd..6f84b58 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/utils/JsonSchemaGenerator.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/tool/utils/JsonSchemaGenerator.java @@ -20,6 +20,7 @@ import java.lang.reflect.Parameter; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -41,6 +42,7 @@ import io.modelcontextprotocol.server.McpAsyncServerExchange; import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.Utils; import io.swagger.v3.oas.annotations.media.Schema; @@ -93,6 +95,30 @@ public static String generateForMethodInput(Method method) { } private static String internalGenerateFromMethodArguments(Method method) { + // Check if method has CallToolRequest parameter + boolean hasCallToolRequestParam = Arrays.stream(method.getParameterTypes()) + .anyMatch(type -> CallToolRequest.class.isAssignableFrom(type)); + + // If method has CallToolRequest, return minimal schema + if (hasCallToolRequestParam) { + // Check if there are other parameters besides CallToolRequest and exchange + // types + boolean hasOtherParams = Arrays.stream(method.getParameters()).anyMatch(param -> { + Class type = param.getType(); + return !CallToolRequest.class.isAssignableFrom(type) + && !McpSyncServerExchange.class.isAssignableFrom(type) + && !McpAsyncServerExchange.class.isAssignableFrom(type); + }); + + // If only CallToolRequest (and possibly exchange), return empty schema + if (!hasOtherParams) { + ObjectNode schema = JsonParser.getObjectMapper().createObjectNode(); + schema.put("type", "object"); + schema.putObject("properties"); + schema.putArray("required"); + return schema.toPrettyString(); + } + } ObjectNode schema = JsonParser.getObjectMapper().createObjectNode(); schema.put("$schema", SchemaVersion.DRAFT_2020_12.getIdentifier()); @@ -104,11 +130,15 @@ private static String internalGenerateFromMethodArguments(Method method) { for (int i = 0; i < method.getParameterCount(); i++) { String parameterName = method.getParameters()[i].getName(); Type parameterType = method.getGenericParameterTypes()[i]; + + // Skip special parameter types if (parameterType instanceof Class parameterClass && (ClassUtils.isAssignable(McpSyncServerExchange.class, parameterClass) - || ClassUtils.isAssignable(McpAsyncServerExchange.class, parameterClass))) { + || ClassUtils.isAssignable(McpAsyncServerExchange.class, parameterClass) + || ClassUtils.isAssignable(CallToolRequest.class, parameterClass))) { continue; } + if (isMethodParameterRequired(method, i)) { required.add(parameterName); } @@ -143,6 +173,15 @@ private static String internalGenerateFromClass(Class clazz) { return jsonSchema.toPrettyString(); } + /** + * Check if a method has a CallToolRequest parameter. + * @param method The method to check + * @return true if the method has a CallToolRequest parameter, false otherwise + */ + public static boolean hasCallToolRequestParameter(Method method) { + return Arrays.stream(method.getParameterTypes()).anyMatch(type -> CallToolRequest.class.isAssignableFrom(type)); + } + private static boolean isMethodParameterRequired(Method method, int index) { Parameter parameter = method.getParameters()[index]; diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/SyncMcpToolProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/SyncMcpToolProvider.java index 2caff19..c2bedb0 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/SyncMcpToolProvider.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/SyncMcpToolProvider.java @@ -17,6 +17,7 @@ package org.springaicommunity.mcp.provider; import java.lang.reflect.Method; +import java.util.Arrays; import java.util.List; import java.util.function.BiFunction; import java.util.stream.Stream; @@ -78,7 +79,21 @@ public List getToolSpecifications() { String toolDescription = toolAnnotation.description(); - String inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); + // Check if method has CallToolRequest parameter + boolean hasCallToolRequestParam = Arrays.stream(mcpToolMethod.getParameterTypes()) + .anyMatch(type -> CallToolRequest.class.isAssignableFrom(type)); + + String inputSchema; + if (hasCallToolRequestParam) { + // For methods with CallToolRequest, generate minimal schema or + // use the one from the request + // The schema generation will handle this appropriately + inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); + logger.debug("Tool method '{}' uses CallToolRequest parameter, using minimal schema", toolName); + } + else { + inputSchema = JsonSchemaGenerator.generateForMethodInput(mcpToolMethod); + } var toolBuilder = McpSchema.Tool.builder() .name(toolName) diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncCallToolRequestSupportTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncCallToolRequestSupportTests.java new file mode 100644 index 0000000..924069c --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncCallToolRequestSupportTests.java @@ -0,0 +1,396 @@ +/* + * 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.method.tool; + +import java.lang.reflect.Method; +import java.util.Map; + +import io.modelcontextprotocol.server.McpAsyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for CallToolRequest parameter support in async MCP tools. + * + * @author Christian Tzolov + */ +public class AsyncCallToolRequestSupportTests { + + private static class AsyncCallToolRequestTestProvider { + + /** + * Async tool that only takes CallToolRequest - for fully dynamic handling + */ + @McpTool(name = "async-dynamic-tool", description = "Async fully dynamic tool") + public Mono asyncDynamicTool(CallToolRequest request) { + // Access full request details + String toolName = request.name(); + Map arguments = request.arguments(); + + // Custom validation + if (!arguments.containsKey("action")) { + return Mono.just(CallToolResult.builder() + .isError(true) + .addTextContent("Missing required 'action' parameter") + .build()); + } + + String action = (String) arguments.get("action"); + return Mono.just(CallToolResult.builder() + .addTextContent("Async processed action: " + action + " for tool: " + toolName) + .build()); + } + + /** + * Async tool with CallToolRequest and Exchange parameters + */ + @McpTool(name = "async-context-aware-tool", description = "Async tool with context and request") + public Mono asyncContextAwareTool(McpAsyncServerExchange exchange, CallToolRequest request) { + // Exchange is available for context + Map arguments = request.arguments(); + + return Mono.just(CallToolResult.builder() + .addTextContent("Async Exchange available: " + (exchange != null) + ", Args: " + arguments.size()) + .build()); + } + + /** + * Async tool with mixed parameters - CallToolRequest plus regular parameters + */ + @McpTool(name = "async-mixed-params-tool", description = "Async tool with mixed parameters") + public Mono asyncMixedParamsTool(CallToolRequest request, + @McpToolParam(description = "Required string parameter", required = true) String requiredParam, + @McpToolParam(description = "Optional integer parameter", required = false) Integer optionalParam) { + + Map allArguments = request.arguments(); + + return Mono.just(CallToolResult.builder() + .addTextContent(String.format("Async Required: %s, Optional: %d, Total args: %d, Tool: %s", + requiredParam, optionalParam != null ? optionalParam : 0, allArguments.size(), request.name())) + .build()); + } + + /** + * Async tool that validates custom schema from CallToolRequest + */ + @McpTool(name = "async-schema-validator", description = "Async validates against custom schema") + public Mono asyncValidateSchema(CallToolRequest request) { + Map arguments = request.arguments(); + + // Custom schema validation logic + boolean hasRequiredFields = arguments.containsKey("data") && arguments.containsKey("format"); + + if (!hasRequiredFields) { + return Mono.just(CallToolResult.builder() + .isError(true) + .addTextContent("Async schema validation failed: missing required fields 'data' and 'format'") + .build()); + } + + return Mono.just(CallToolResult.builder() + .addTextContent("Async schema validation successful for: " + request.name()) + .build()); + } + + /** + * Regular async tool without CallToolRequest for comparison + */ + @McpTool(name = "async-regular-tool", description = "Regular async tool without CallToolRequest") + public Mono asyncRegularTool(String input, int number) { + return Mono.just("Async Regular: " + input + " - " + number); + } + + /** + * Async tool that returns structured output + */ + @McpTool(name = "async-structured-output-tool", description = "Async tool with structured output") + public Mono asyncStructuredOutputTool(CallToolRequest request) { + Map arguments = request.arguments(); + String input = (String) arguments.get("input"); + + return Mono.just(new TestResult(input != null ? input : "default", 42)); + } + + /** + * Async tool that returns Mono + */ + @McpTool(name = "async-void-tool", description = "Async tool that returns void") + public Mono asyncVoidTool(CallToolRequest request) { + // Perform some side effect + Map arguments = request.arguments(); + System.out.println("Processing: " + arguments); + return Mono.empty(); + } + + /** + * Async tool that throws an error + */ + @McpTool(name = "async-error-tool", description = "Async tool that throws error") + public Mono asyncErrorTool(CallToolRequest request) { + return Mono.error(new RuntimeException("Async tool execution failed")); + } + + } + + public static class TestResult { + + public String message; + + public int value; + + public TestResult(String message, int value) { + this.message = message; + this.value = value; + } + + } + + @Test + public void testAsyncDynamicToolWithCallToolRequest() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncDynamicTool", CallToolRequest.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("async-dynamic-tool", + Map.of("action", "analyze", "data", "test-data")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + 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("Async processed action: analyze for tool: async-dynamic-tool"); + }).verifyComplete(); + } + + @Test + public void testAsyncDynamicToolMissingRequiredParameter() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncDynamicTool", CallToolRequest.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("async-dynamic-tool", Map.of("data", "test-data")); // Missing + // 'action' + // parameter + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isTrue(); + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Missing required 'action' parameter"); + }).verifyComplete(); + } + + @Test + public void testAsyncErrorToolWithCallToolRequest() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncErrorTool", CallToolRequest.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("async-error-tool", Map.of("data", "test")); + + Mono resultMono = callback.apply(exchange, request); + + // When a method returns Mono.error(), it propagates as an error + StepVerifier.create(resultMono) + .expectErrorMatches(throwable -> throwable instanceof RuntimeException + && throwable.getMessage().contains("Async tool execution failed")) + .verify(); + } + + @Test + public void testAsyncMixedParametersTool() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncMixedParamsTool", CallToolRequest.class, + String.class, Integer.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("async-mixed-params-tool", + Map.of("requiredParam", "test-value", "optionalParam", 42, "extraParam", "extra")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()) + .isEqualTo("Async Required: test-value, Optional: 42, Total args: 3, Tool: async-mixed-params-tool"); + }).verifyComplete(); + } + + @Test + public void testAsyncMixedParametersToolWithNullOptional() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncMixedParamsTool", CallToolRequest.class, + String.class, Integer.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("async-mixed-params-tool", Map.of("requiredParam", "test-value")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()) + .isEqualTo("Async Required: test-value, Optional: 0, Total args: 1, Tool: async-mixed-params-tool"); + }).verifyComplete(); + } + + @Test + public void testAsyncSchemaValidatorTool() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncValidateSchema", CallToolRequest.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + + // Test with valid schema + CallToolRequest validRequest = new CallToolRequest("async-schema-validator", + Map.of("data", "test-data", "format", "json")); + + Mono validResultMono = callback.apply(exchange, validRequest); + + StepVerifier.create(validResultMono).assertNext(result -> { + assertThat(result.isError()).isFalse(); + assertThat(((TextContent) result.content().get(0)).text()) + .isEqualTo("Async schema validation successful for: async-schema-validator"); + }).verifyComplete(); + + // Test with invalid schema + CallToolRequest invalidRequest = new CallToolRequest("async-schema-validator", Map.of("data", "test-data")); // Missing + // 'format' + + Mono invalidResultMono = callback.apply(exchange, invalidRequest); + + StepVerifier.create(invalidResultMono).assertNext(result -> { + assertThat(result.isError()).isTrue(); + assertThat(((TextContent) result.content().get(0)).text()).contains("Async schema validation failed"); + }).verifyComplete(); + } + + @Test + public void testAsyncStructuredOutputWithCallToolRequest() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncStructuredOutputTool", + CallToolRequest.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.STRUCTURED, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("async-structured-output-tool", Map.of("input", "test-message")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.structuredContent()).isNotNull(); + assertThat(result.structuredContent()).containsEntry("message", "test-message"); + assertThat(result.structuredContent()).containsEntry("value", 42); + }).verifyComplete(); + } + + @Test + public void testAsyncVoidToolWithCallToolRequest() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncVoidTool", CallToolRequest.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.VOID, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("async-void-tool", Map.of("action", "process")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + // Void methods should return "Done" + assertThat(((TextContent) result.content().get(0)).text()).contains("Done"); + }).verifyComplete(); + } + + @Test + public void testAsyncCallToolRequestParameterInjection() throws Exception { + // Test that CallToolRequest is properly injected as a parameter + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncDynamicTool", CallToolRequest.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + CallToolRequest request = new CallToolRequest("async-dynamic-tool", Map.of("action", "test", "data", "sample")); + + Mono resultMono = callback.apply(exchange, request); + + StepVerifier.create(resultMono).assertNext(result -> { + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + // The tool should have access to the full request including the tool name + assertThat(((TextContent) result.content().get(0)).text()).contains("tool: async-dynamic-tool"); + }).verifyComplete(); + } + + @Test + public void testAsyncNullRequest() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncDynamicTool", CallToolRequest.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpAsyncServerExchange exchange = mock(McpAsyncServerExchange.class); + + Mono resultMono = callback.apply(exchange, null); + + StepVerifier.create(resultMono).expectError(IllegalArgumentException.class).verify(); + } + + @Test + public void testAsyncIsExchangeType() throws Exception { + AsyncCallToolRequestTestProvider provider = new AsyncCallToolRequestTestProvider(); + Method method = AsyncCallToolRequestTestProvider.class.getMethod("asyncDynamicTool", CallToolRequest.class); + AsyncMcpToolMethodCallback callback = new AsyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + // Test that McpAsyncServerExchange is recognized as exchange type + assertThat(callback.isExchangeOrContextType(McpAsyncServerExchange.class)).isTrue(); + + // Test that other types are not recognized as exchange type + assertThat(callback.isExchangeOrContextType(String.class)).isFalse(); + assertThat(callback.isExchangeOrContextType(Integer.class)).isFalse(); + assertThat(callback.isExchangeOrContextType(Object.class)).isFalse(); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java index c1a6c14..b03a8be 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/AsyncStatelessMcpToolMethodCallbackTests.java @@ -153,6 +153,21 @@ public String nonReactiveTool(String input) { return "Non-reactive: " + input; } + @McpTool(name = "call-tool-request-mono-tool", description = "Mono tool with CallToolRequest parameter") + public Mono monoToolWithCallToolRequest(CallToolRequest request) { + return Mono.just("Received tool: " + request.name() + " with " + request.arguments().size() + " arguments"); + } + + @McpTool(name = "mixed-params-mono-tool", description = "Mono tool with mixed parameters") + public Mono monoToolWithMixedParams(String action, CallToolRequest request) { + return Mono.just("Action: " + action + ", Tool: " + request.name()); + } + + @McpTool(name = "context-and-request-mono-tool", description = "Mono tool with context and request") + public Mono monoToolWithContextAndRequest(McpTransportContext context, CallToolRequest request) { + return Mono.just("Context present, Tool: " + request.name()); + } + } public static class TestObject { @@ -767,4 +782,68 @@ public void testCallbackReturnsCallToolResult() throws Exception { }).verifyComplete(); } + @Test + public void testMonoToolWithCallToolRequest() throws Exception { + TestAsyncStatelessToolProvider provider = new TestAsyncStatelessToolProvider(); + Method method = TestAsyncStatelessToolProvider.class.getMethod("monoToolWithCallToolRequest", + CallToolRequest.class); + AsyncStatelessMcpToolMethodCallback callback = new AsyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("call-tool-request-mono-tool", + Map.of("param1", "value1", "param2", "value2")); + + StepVerifier.create(callback.apply(context, request)).assertNext(result -> { + 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("Received tool: call-tool-request-mono-tool with 2 arguments"); + }).verifyComplete(); + } + + @Test + public void testMonoToolWithMixedParams() throws Exception { + TestAsyncStatelessToolProvider provider = new TestAsyncStatelessToolProvider(); + Method method = TestAsyncStatelessToolProvider.class.getMethod("monoToolWithMixedParams", String.class, + CallToolRequest.class); + AsyncStatelessMcpToolMethodCallback callback = new AsyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("mixed-params-mono-tool", Map.of("action", "process")); + + StepVerifier.create(callback.apply(context, request)).assertNext(result -> { + 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("Action: process, Tool: mixed-params-mono-tool"); + }).verifyComplete(); + } + + @Test + public void testMonoToolWithContextAndRequest() throws Exception { + TestAsyncStatelessToolProvider provider = new TestAsyncStatelessToolProvider(); + Method method = TestAsyncStatelessToolProvider.class.getMethod("monoToolWithContextAndRequest", + McpTransportContext.class, CallToolRequest.class); + AsyncStatelessMcpToolMethodCallback callback = new AsyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("context-and-request-mono-tool", Map.of()); + + StepVerifier.create(callback.apply(context, request)).assertNext(result -> { + 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 present, Tool: context-and-request-mono-tool"); + }).verifyComplete(); + } + } diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/CallToolRequestSupportTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/CallToolRequestSupportTests.java new file mode 100644 index 0000000..23cd345 --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/CallToolRequestSupportTests.java @@ -0,0 +1,433 @@ +/* + * 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.method.tool; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; + +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpTool; +import org.springaicommunity.mcp.annotation.McpToolParam; +import org.springaicommunity.mcp.method.tool.utils.JsonSchemaGenerator; +import org.springaicommunity.mcp.provider.SyncMcpToolProvider; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JsonNode; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for CallToolRequest parameter support in MCP tools. + * + * @author Christian Tzolov + */ +public class CallToolRequestSupportTests { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static class CallToolRequestTestProvider { + + /** + * Tool that only takes CallToolRequest - for fully dynamic handling + */ + @McpTool(name = "dynamic-tool", description = "Fully dynamic tool") + public CallToolResult dynamicTool(CallToolRequest request) { + // Access full request details + String toolName = request.name(); + Map arguments = request.arguments(); + + // Custom validation + if (!arguments.containsKey("action")) { + return CallToolResult.builder() + .isError(true) + .addTextContent("Missing required 'action' parameter") + .build(); + } + + String action = (String) arguments.get("action"); + return CallToolResult.builder() + .addTextContent("Processed action: " + action + " for tool: " + toolName) + .build(); + } + + /** + * Tool with CallToolRequest and Exchange parameters + */ + @McpTool(name = "context-aware-tool", description = "Tool with context and request") + public CallToolResult contextAwareTool(McpSyncServerExchange exchange, CallToolRequest request) { + // Exchange is available for context + Map arguments = request.arguments(); + + return CallToolResult.builder() + .addTextContent("Exchange available: " + (exchange != null) + ", Args: " + arguments.size()) + .build(); + } + + /** + * Tool with mixed parameters - CallToolRequest plus regular parameters + */ + @McpTool(name = "mixed-params-tool", description = "Tool with mixed parameters") + public CallToolResult mixedParamsTool(CallToolRequest request, + @McpToolParam(description = "Required string parameter", required = true) String requiredParam, + @McpToolParam(description = "Optional integer parameter", required = false) Integer optionalParam) { + + Map allArguments = request.arguments(); + + return CallToolResult.builder() + .addTextContent(String.format("Required: %s, Optional: %d, Total args: %d, Tool: %s", requiredParam, + optionalParam != null ? optionalParam : 0, allArguments.size(), request.name())) + .build(); + } + + /** + * Tool that validates custom schema from CallToolRequest + */ + @McpTool(name = "schema-validator", description = "Validates against custom schema") + public CallToolResult validateSchema(CallToolRequest request) { + Map arguments = request.arguments(); + + // Custom schema validation logic + boolean hasRequiredFields = arguments.containsKey("data") && arguments.containsKey("format"); + + if (!hasRequiredFields) { + return CallToolResult.builder() + .isError(true) + .addTextContent("Schema validation failed: missing required fields 'data' and 'format'") + .build(); + } + + return CallToolResult.builder() + .addTextContent("Schema validation successful for: " + request.name()) + .build(); + } + + /** + * Regular tool without CallToolRequest for comparison + */ + @McpTool(name = "regular-tool", description = "Regular tool without CallToolRequest") + public String regularTool(String input, int number) { + return "Regular: " + input + " - " + number; + } + + /** + * Tool that returns structured output + */ + @McpTool(name = "structured-output-tool", description = "Tool with structured output") + public TestResult structuredOutputTool(CallToolRequest request) { + Map arguments = request.arguments(); + String input = (String) arguments.get("input"); + + return new TestResult(input != null ? input : "default", 42); + } + + } + + public static class TestResult { + + public String message; + + public int value; + + public TestResult(String message, int value) { + this.message = message; + this.value = value; + } + + } + + @Test + public void testDynamicToolWithCallToolRequest() throws Exception { + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("dynamicTool", CallToolRequest.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CallToolRequest request = new CallToolRequest("dynamic-tool", Map.of("action", "analyze", "data", "test-data")); + + CallToolResult result = callback.apply(exchange, 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 action: analyze for tool: dynamic-tool"); + } + + @Test + public void testDynamicToolMissingRequiredParameter() throws Exception { + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("dynamicTool", CallToolRequest.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CallToolRequest request = new CallToolRequest("dynamic-tool", Map.of("data", "test-data")); // Missing + // 'action' + // parameter + + CallToolResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isTrue(); + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Missing required 'action' parameter"); + } + + @Test + public void testContextAwareToolWithCallToolRequestAndExchange() throws Exception { + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("contextAwareTool", McpSyncServerExchange.class, + CallToolRequest.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + + CallToolRequest request = new CallToolRequest("context-aware-tool", Map.of("key1", "value1", "key2", "value2")); + + CallToolResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("Exchange available: true, Args: 2"); + } + + @Test + public void testMixedParametersTool() throws Exception { + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("mixedParamsTool", CallToolRequest.class, + String.class, Integer.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CallToolRequest request = new CallToolRequest("mixed-params-tool", + Map.of("requiredParam", "test-value", "optionalParam", 42, "extraParam", "extra")); + + CallToolResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()) + .isEqualTo("Required: test-value, Optional: 42, Total args: 3, Tool: mixed-params-tool"); + } + + @Test + public void testMixedParametersToolWithNullOptional() throws Exception { + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("mixedParamsTool", CallToolRequest.class, + String.class, Integer.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CallToolRequest request = new CallToolRequest("mixed-params-tool", Map.of("requiredParam", "test-value")); + + CallToolResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.content()).hasSize(1); + assertThat(((TextContent) result.content().get(0)).text()) + .isEqualTo("Required: test-value, Optional: 0, Total args: 1, Tool: mixed-params-tool"); + } + + @Test + public void testSchemaValidatorTool() throws Exception { + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("validateSchema", CallToolRequest.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + + // Test with valid schema + CallToolRequest validRequest = new CallToolRequest("schema-validator", + Map.of("data", "test-data", "format", "json")); + + CallToolResult validResult = callback.apply(exchange, validRequest); + assertThat(validResult.isError()).isFalse(); + assertThat(((TextContent) validResult.content().get(0)).text()) + .isEqualTo("Schema validation successful for: schema-validator"); + + // Test with invalid schema + CallToolRequest invalidRequest = new CallToolRequest("schema-validator", Map.of("data", "test-data")); // Missing + // 'format' + + CallToolResult invalidResult = callback.apply(exchange, invalidRequest); + assertThat(invalidResult.isError()).isTrue(); + assertThat(((TextContent) invalidResult.content().get(0)).text()).contains("Schema validation failed"); + } + + @Test + public void testJsonSchemaGenerationForCallToolRequest() throws Exception { + // Test that schema generation handles CallToolRequest properly + Method dynamicMethod = CallToolRequestTestProvider.class.getMethod("dynamicTool", CallToolRequest.class); + String dynamicSchema = JsonSchemaGenerator.generateForMethodInput(dynamicMethod); + + // Parse the schema + JsonNode schemaNode = objectMapper.readTree(dynamicSchema); + + // Should have minimal schema with empty properties + assertThat(schemaNode.has("type")).isTrue(); + assertThat(schemaNode.get("type").asText()).isEqualTo("object"); + assertThat(schemaNode.has("properties")).isTrue(); + assertThat(schemaNode.get("properties").size()).isEqualTo(0); + assertThat(schemaNode.has("required")).isTrue(); + assertThat(schemaNode.get("required").size()).isEqualTo(0); + } + + @Test + public void testJsonSchemaGenerationForMixedParameters() throws Exception { + // Test schema generation for method with CallToolRequest and other parameters + Method mixedMethod = CallToolRequestTestProvider.class.getMethod("mixedParamsTool", CallToolRequest.class, + String.class, Integer.class); + String mixedSchema = JsonSchemaGenerator.generateForMethodInput(mixedMethod); + + // Parse the schema + JsonNode schemaNode = objectMapper.readTree(mixedSchema); + + // Should have schema for non-CallToolRequest parameters only + assertThat(schemaNode.has("properties")).isTrue(); + JsonNode properties = schemaNode.get("properties"); + assertThat(properties.has("requiredParam")).isTrue(); + assertThat(properties.has("optionalParam")).isTrue(); + assertThat(properties.size()).isEqualTo(2); // Only the regular parameters + + // Check required array + assertThat(schemaNode.has("required")).isTrue(); + JsonNode required = schemaNode.get("required"); + assertThat(required.size()).isEqualTo(1); + assertThat(required.get(0).asText()).isEqualTo("requiredParam"); + } + + @Test + public void testJsonSchemaGenerationForRegularTool() throws Exception { + // Test that regular tools still work as before + Method regularMethod = CallToolRequestTestProvider.class.getMethod("regularTool", String.class, int.class); + String regularSchema = JsonSchemaGenerator.generateForMethodInput(regularMethod); + + // Parse the schema + JsonNode schemaNode = objectMapper.readTree(regularSchema); + + // Should have normal schema with all parameters + assertThat(schemaNode.has("properties")).isTrue(); + JsonNode properties = schemaNode.get("properties"); + assertThat(properties.has("input")).isTrue(); + assertThat(properties.has("number")).isTrue(); + assertThat(properties.size()).isEqualTo(2); + } + + @Test + public void testHasCallToolRequestParameter() throws Exception { + // Test the utility method + Method dynamicMethod = CallToolRequestTestProvider.class.getMethod("dynamicTool", CallToolRequest.class); + assertThat(JsonSchemaGenerator.hasCallToolRequestParameter(dynamicMethod)).isTrue(); + + Method regularMethod = CallToolRequestTestProvider.class.getMethod("regularTool", String.class, int.class); + assertThat(JsonSchemaGenerator.hasCallToolRequestParameter(regularMethod)).isFalse(); + + Method mixedMethod = CallToolRequestTestProvider.class.getMethod("mixedParamsTool", CallToolRequest.class, + String.class, Integer.class); + assertThat(JsonSchemaGenerator.hasCallToolRequestParameter(mixedMethod)).isTrue(); + } + + @Test + public void testSyncMcpToolProviderWithCallToolRequest() { + // Test that SyncMcpToolProvider handles CallToolRequest tools correctly + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + SyncMcpToolProvider toolProvider = new SyncMcpToolProvider(List.of(provider)); + + var toolSpecs = toolProvider.getToolSpecifications(); + + // Should have all tools registered + assertThat(toolSpecs).hasSize(6); // All 6 tools from the provider + + // Find the dynamic tool + var dynamicToolSpec = toolSpecs.stream() + .filter(spec -> spec.tool().name().equals("dynamic-tool")) + .findFirst() + .orElse(null); + + assertThat(dynamicToolSpec).isNotNull(); + assertThat(dynamicToolSpec.tool().description()).isEqualTo("Fully dynamic tool"); + + // The input schema should be minimal + var inputSchema = dynamicToolSpec.tool().inputSchema(); + assertThat(inputSchema).isNotNull(); + // Convert to string if it's a JsonSchema object + String schemaStr = inputSchema.toString(); + assertThat(schemaStr).isNotNull(); + + // Find the mixed params tool + var mixedToolSpec = toolSpecs.stream() + .filter(spec -> spec.tool().name().equals("mixed-params-tool")) + .findFirst() + .orElse(null); + + assertThat(mixedToolSpec).isNotNull(); + // The input schema should contain only the regular parameters + var mixedSchema = mixedToolSpec.tool().inputSchema(); + assertThat(mixedSchema).isNotNull(); + // Convert to string if it's a JsonSchema object + String mixedSchemaStr = mixedSchema.toString(); + assertThat(mixedSchemaStr).contains("requiredParam"); + assertThat(mixedSchemaStr).contains("optionalParam"); + } + + @Test + public void testStructuredOutputWithCallToolRequest() throws Exception { + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("structuredOutputTool", CallToolRequest.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.STRUCTURED, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CallToolRequest request = new CallToolRequest("structured-output-tool", Map.of("input", "test-message")); + + CallToolResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + assertThat(result.structuredContent()).isNotNull(); + assertThat(result.structuredContent()).containsEntry("message", "test-message"); + assertThat(result.structuredContent()).containsEntry("value", 42); + } + + @Test + public void testCallToolRequestParameterInjection() throws Exception { + // Test that CallToolRequest is properly injected as a parameter + CallToolRequestTestProvider provider = new CallToolRequestTestProvider(); + Method method = CallToolRequestTestProvider.class.getMethod("dynamicTool", CallToolRequest.class); + SyncMcpToolMethodCallback callback = new SyncMcpToolMethodCallback(ReturnMode.TEXT, method, provider); + + McpSyncServerExchange exchange = mock(McpSyncServerExchange.class); + CallToolRequest request = new CallToolRequest("dynamic-tool", Map.of("action", "test", "data", "sample")); + + // The callback should properly inject the CallToolRequest + CallToolResult result = callback.apply(exchange, request); + + assertThat(result).isNotNull(); + assertThat(result.isError()).isFalse(); + // The tool should have access to the full request including the tool name + assertThat(((TextContent) result.content().get(0)).text()).contains("tool: dynamic-tool"); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallbackTests.java index 6996222..d091eeb 100644 --- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallbackTests.java +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/tool/SyncStatelessMcpToolMethodCallbackTests.java @@ -111,6 +111,21 @@ public void voidTool(String input) { // Do nothing } + @McpTool(name = "call-tool-request-tool", description = "Tool with CallToolRequest parameter") + public String toolWithCallToolRequest(CallToolRequest request) { + return "Received tool: " + request.name() + " with " + request.arguments().size() + " arguments"; + } + + @McpTool(name = "mixed-params-tool", description = "Tool with mixed parameters") + public String toolWithMixedParams(String action, CallToolRequest request) { + return "Action: " + action + ", Tool: " + request.name(); + } + + @McpTool(name = "context-and-request-tool", description = "Tool with context and request") + public String toolWithContextAndRequest(McpTransportContext context, CallToolRequest request) { + return "Context present, Tool: " + request.name(); + } + } public static class TestObject { @@ -535,4 +550,66 @@ public void testVoidReturnMode() throws Exception { assertThat(((TextContent) result.content().get(0)).text()).isEqualTo("\"Done\""); } + @Test + public void testToolWithCallToolRequest() throws Exception { + TestToolProvider provider = new TestToolProvider(); + Method method = TestToolProvider.class.getMethod("toolWithCallToolRequest", CallToolRequest.class); + SyncStatelessMcpToolMethodCallback callback = new SyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("call-tool-request-tool", + Map.of("param1", "value1", "param2", "value2")); + + CallToolResult result = callback.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("Received tool: call-tool-request-tool with 2 arguments"); + } + + @Test + public void testToolWithMixedParams() throws Exception { + TestToolProvider provider = new TestToolProvider(); + Method method = TestToolProvider.class.getMethod("toolWithMixedParams", String.class, CallToolRequest.class); + SyncStatelessMcpToolMethodCallback callback = new SyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("mixed-params-tool", Map.of("action", "process")); + + CallToolResult result = callback.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("Action: process, Tool: mixed-params-tool"); + } + + @Test + public void testToolWithContextAndRequest() throws Exception { + TestToolProvider provider = new TestToolProvider(); + Method method = TestToolProvider.class.getMethod("toolWithContextAndRequest", McpTransportContext.class, + CallToolRequest.class); + SyncStatelessMcpToolMethodCallback callback = new SyncStatelessMcpToolMethodCallback(ReturnMode.TEXT, method, + provider); + + McpTransportContext context = mock(McpTransportContext.class); + CallToolRequest request = new CallToolRequest("context-and-request-tool", Map.of()); + + CallToolResult result = callback.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 present, Tool: context-and-request-tool"); + } + }