From 3083eb53a31645fea805891f010145ab2762dc75 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Tue, 19 Aug 2025 15:07:48 +0200 Subject: [PATCH] feat: add elicitation support to Spring annotation providers - Add createAsyncElicitationHandler() method to AsyncMcpAnnotationProvider - Add createSyncElicitationHandler() method to SyncMcpAnnotationProvider - Implement SpringAiAsyncMcpElicitationProvider inner class - Implement SpringAiSyncMcpElicitationProvider inner class - Add ElicitRequest and ElicitResult imports to both providers - Update README with comprehensive elicitation documentation: - Add elicitation to core module operations list - Document @McpElicitation annotation - Add elicitation method callbacks documentation - Add elicitation providers documentation - Include complete usage examples for sync and async handlers - Add Spring integration examples for elicitation - Bump MCP Java SDK version from 0.11.0-SNAPSHOT to 0.12.0-SNAPSHOT This enables Spring applications to easily integrate MCP elicitation functionality using the familiar annotation-based approach, providing both synchronous and asynchronous implementations for gathering additional information from users. Signed-off-by: Christian Tzolov --- README.md | 143 +++++++++++ .../spring/AsyncMcpAnnotationProvider.java | 21 ++ .../mcp/spring/SyncMcpAnnotationProvider.java | 20 ++ .../mcp/annotation/McpElicitation.java | 54 ++++ .../AbstractMcpElicitationMethodCallback.java | 239 ++++++++++++++++++ .../AsyncMcpElicitationMethodCallback.java | 135 ++++++++++ .../SyncMcpElicitationMethodCallback.java | 119 +++++++++ .../AbstractMcpSamplingMethodCallback.java | 2 - .../provider/AsyncMcpElicitationProvider.java | 124 +++++++++ .../provider/SyncMcpElicitationProvider.java | 123 +++++++++ ...ncMcpElicitationMethodCallbackExample.java | 49 ++++ ...ncMcpElicitationMethodCallbackExample.java | 40 +++ .../AsyncMcpElicitationProviderTests.java | 116 +++++++++ .../SyncMcpElicitationProviderTests.java | 85 +++++++ pom.xml | 2 +- 15 files changed, 1269 insertions(+), 3 deletions(-) create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpElicitation.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AbstractMcpElicitationMethodCallback.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallback.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallback.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/AsyncMcpElicitationProvider.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/SyncMcpElicitationProvider.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallbackExample.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallbackExample.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/AsyncMcpElicitationProviderTests.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/SyncMcpElicitationProviderTests.java diff --git a/README.md b/README.md index b9fc714..4ebe542 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,7 @@ The core module provides a set of annotations and callback implementations for p 4. **Tool** - For implementing MCP tools with automatic JSON schema generation 5. **Logging Consumer** - For handling logging message notifications 6. **Sampling** - For handling sampling requests +7. **Elicitation** - For handling elicitation requests to gather additional information from users Each operation type has both synchronous and asynchronous implementations, allowing for flexible integration with different application architectures. @@ -110,6 +111,7 @@ The Spring integration module provides seamless integration with Spring AI and S - **`@McpToolParam`** - Annotates tool method parameters with descriptions and requirement specifications - **`@McpLoggingConsumer`** - Annotates methods that handle logging message notifications from MCP servers - **`@McpSampling`** - Annotates methods that handle sampling requests from MCP servers +- **`@McpElicitation`** - Annotates methods that handle elicitation requests to gather additional information from users - **`@McpArg`** - Annotates method parameters as MCP arguments ### Method Callbacks @@ -145,6 +147,11 @@ The modules provide callback implementations for each operation type: - `SyncMcpSamplingMethodCallback` - Synchronous implementation - `AsyncMcpSamplingMethodCallback` - Asynchronous implementation using Reactor's Mono +#### Elicitation +- `AbstractMcpElicitationMethodCallback` - Base class for elicitation method callbacks +- `SyncMcpElicitationMethodCallback` - Synchronous implementation +- `AsyncMcpElicitationMethodCallback` - Asynchronous implementation using Reactor's Mono + ### Providers The project includes provider classes that scan for annotated methods and create appropriate callbacks: @@ -158,6 +165,8 @@ The project includes provider classes that scan for annotated methods and create - `AsyncMcpLoggingConsumerProvider` - Processes `@McpLoggingConsumer` annotations for asynchronous operations - `SyncMcpSamplingProvider` - Processes `@McpSampling` annotations for synchronous operations - `AsyncMcpSamplingProvider` - Processes `@McpSampling` annotations for asynchronous operations +- `SyncMcpElicitationProvider` - Processes `@McpElicitation` annotations for synchronous operations +- `AsyncMcpElicitationProvider` - Processes `@McpElicitation` annotations for asynchronous operations ### Spring Integration @@ -650,6 +659,128 @@ public class MyMcpClient { } ``` +### Mcp Client Elicitation Example + +```java +public class ElicitationHandler { + + /** + * Handle elicitation requests with a synchronous implementation. + * @param request The elicitation request + * @return The elicitation result + */ + @McpElicitation + public ElicitResult handleElicitationRequest(ElicitRequest request) { + // Example implementation that accepts the request and returns user data + // In a real implementation, this would present a form to the user + // and collect their input based on the requested schema + + Map userData = new HashMap<>(); + + // Check what information is being requested based on the schema + Map schema = request.requestedSchema(); + if (schema != null && schema.containsKey("properties")) { + @SuppressWarnings("unchecked") + Map properties = (Map) schema.get("properties"); + + // Simulate user providing the requested information + if (properties.containsKey("name")) { + userData.put("name", "John Doe"); + } + if (properties.containsKey("email")) { + userData.put("email", "john.doe@example.com"); + } + if (properties.containsKey("age")) { + userData.put("age", 30); + } + if (properties.containsKey("preferences")) { + userData.put("preferences", Map.of("theme", "dark", "notifications", true)); + } + } + + return new ElicitResult(ElicitResult.Action.ACCEPT, userData); + } + + /** + * Handle elicitation requests that should be declined. + * @param request The elicitation request + * @return The elicitation result with decline action + */ + @McpElicitation + public ElicitResult handleDeclineElicitationRequest(ElicitRequest request) { + // Example of declining an elicitation request + return new ElicitResult(ElicitResult.Action.DECLINE, null); + } +} + +public class AsyncElicitationHandler { + + /** + * Handle elicitation requests with an asynchronous implementation. + * @param request The elicitation request + * @return A Mono containing the elicitation result + */ + @McpElicitation + public Mono handleAsyncElicitationRequest(ElicitRequest request) { + return Mono.fromCallable(() -> { + // Simulate async processing of the elicitation request + // In a real implementation, this might involve showing a UI form + // and waiting for user input + + Map userData = new HashMap<>(); + userData.put("response", "Async elicitation response"); + userData.put("timestamp", System.currentTimeMillis()); + userData.put("message", request.message()); + + return new ElicitResult(ElicitResult.Action.ACCEPT, userData); + }).delayElement(Duration.ofMillis(100)); // Simulate processing delay + } + + /** + * Handle elicitation requests that might be cancelled. + * @param request The elicitation request + * @return A Mono containing the elicitation result with cancel action + */ + @McpElicitation + public Mono handleCancelElicitationRequest(ElicitRequest request) { + return Mono.just(new ElicitResult(ElicitResult.Action.CANCEL, null)); + } +} + +public class MyMcpClient { + + public static McpSyncClient createSyncClientWithElicitation(ElicitationHandler elicitationHandler) { + Function elicitationHandler = + new SyncMcpElicitationProvider(List.of(elicitationHandler)).getElicitationHandler(); + + McpSyncClient client = McpClient.sync(transport) + .capabilities(ClientCapabilities.builder() + .elicitation() // Enable elicitation support + // Other capabilities... + .build()) + .elicitationHandler(elicitationHandler) + .build(); + + return client; + } + + public static McpAsyncClient createAsyncClientWithElicitation(AsyncElicitationHandler asyncElicitationHandler) { + Function> elicitationHandler = + new AsyncMcpElicitationProvider(List.of(asyncElicitationHandler)).getElicitationHandler(); + + McpAsyncClient client = McpClient.async(transport) + .capabilities(ClientCapabilities.builder() + .elicitation() // Enable elicitation support + // Other capabilities... + .build()) + .elicitationHandler(elicitationHandler) + .build(); + + return client; + } +} +``` + ### Spring Integration Example @@ -704,6 +835,18 @@ public class McpConfig { List asyncSamplingHandlers) { return SpringAiMcpAnnotationProvider.createAsyncSamplingHandler(asyncSamplingHandlers); } + + @Bean + public Function syncElicitationHandler( + List elicitationHandlers) { + return SpringAiMcpAnnotationProvider.createSyncElicitationHandler(elicitationHandlers); + } + + @Bean + public Function> asyncElicitationHandler( + List asyncElicitationHandlers) { + return SpringAiMcpAnnotationProvider.createAsyncElicitationHandler(asyncElicitationHandlers); + } } ``` diff --git a/mcp-annotations-spring/src/main/java/org/springaicommunity/mcp/spring/AsyncMcpAnnotationProvider.java b/mcp-annotations-spring/src/main/java/org/springaicommunity/mcp/spring/AsyncMcpAnnotationProvider.java index 9a29a2c..67dc3ce 100644 --- a/mcp-annotations-spring/src/main/java/org/springaicommunity/mcp/spring/AsyncMcpAnnotationProvider.java +++ b/mcp-annotations-spring/src/main/java/org/springaicommunity/mcp/spring/AsyncMcpAnnotationProvider.java @@ -19,6 +19,7 @@ import java.util.List; import java.util.function.Function; +import org.springaicommunity.mcp.provider.AsyncMcpElicitationProvider; import org.springaicommunity.mcp.provider.AsyncMcpLoggingConsumerProvider; import org.springaicommunity.mcp.provider.AsyncMcpSamplingProvider; import org.springaicommunity.mcp.provider.AsyncMcpToolProvider; @@ -26,6 +27,8 @@ import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification; import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; import reactor.core.publisher.Mono; @@ -60,6 +63,19 @@ protected Method[] doGetClassMethods(Object bean) { } + private static class SpringAiAsyncMcpElicitationProvider extends AsyncMcpElicitationProvider { + + public SpringAiAsyncMcpElicitationProvider(List elicitationObjects) { + super(elicitationObjects); + } + + @Override + protected Method[] doGetClassMethods(Object bean) { + return AnnotationProviderUtil.beanMethods(bean); + } + + } + private static class SpringAiAsyncMcpToolProvider extends AsyncMcpToolProvider { public SpringAiAsyncMcpToolProvider(List toolObjects) { @@ -83,6 +99,11 @@ public static Function> createAs return new SpringAiAsyncMcpSamplingProvider(samplingObjects).getSamplingHandler(); } + public static Function> createAsyncElicitationHandler( + List elicitationObjects) { + return new SpringAiAsyncMcpElicitationProvider(elicitationObjects).getElicitationHandler(); + } + public static List createAsyncToolSpecifications(List toolObjects) { return new SpringAiAsyncMcpToolProvider(toolObjects).getToolSpecifications(); } diff --git a/mcp-annotations-spring/src/main/java/org/springaicommunity/mcp/spring/SyncMcpAnnotationProvider.java b/mcp-annotations-spring/src/main/java/org/springaicommunity/mcp/spring/SyncMcpAnnotationProvider.java index c91a6c0..7ac4738 100644 --- a/mcp-annotations-spring/src/main/java/org/springaicommunity/mcp/spring/SyncMcpAnnotationProvider.java +++ b/mcp-annotations-spring/src/main/java/org/springaicommunity/mcp/spring/SyncMcpAnnotationProvider.java @@ -21,6 +21,7 @@ import java.util.function.Function; import org.springaicommunity.mcp.provider.SyncMcpCompletionProvider; +import org.springaicommunity.mcp.provider.SyncMcpElicitationProvider; import org.springaicommunity.mcp.provider.SyncMcpLoggingConsumerProvider; import org.springaicommunity.mcp.provider.SyncMcpPromptProvider; import org.springaicommunity.mcp.provider.SyncMcpResourceProvider; @@ -33,6 +34,8 @@ import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; /** @@ -118,6 +121,19 @@ protected Method[] doGetClassMethods(Object bean) { } + private static class SpringAiSyncMcpElicitationProvider extends SyncMcpElicitationProvider { + + public SpringAiSyncMcpElicitationProvider(List elicitationObjects) { + super(elicitationObjects); + } + + @Override + protected Method[] doGetClassMethods(Object bean) { + return AnnotationProviderUtil.beanMethods(bean); + } + + } + public static List createSyncToolSpecifications(List toolObjects) { return new SpringAiSyncToolProvider(toolObjects).getToolSpecifications(); } @@ -143,4 +159,8 @@ public static Function createSyncSamp return new SpringAiSyncMcpSamplingProvider(samplingObjects).getSamplingHandler(); } + public static Function createSyncElicitationHandler(List elicitationObjects) { + return new SpringAiSyncMcpElicitationProvider(elicitationObjects).getElicitationHandler(); + } + } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpElicitation.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpElicitation.java new file mode 100644 index 0000000..b846b0a --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpElicitation.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation for methods that handle elicitation requests from MCP servers. + * + *

+ * Methods annotated with this annotation can be used to process elicitation requests from + * MCP servers. + * + *

+ * For synchronous handlers, the method must return {@code ElicitResult}. For asynchronous + * handlers, the method must return {@code Mono}. + * + *

+ * Example usage:

{@code
+ * @McpElicitation
+ * public ElicitResult handleElicitationRequest(ElicitRequest request) {
+ *     return ElicitResult.builder()
+ *         .message("Generated response")
+ *         .requestedSchema(
+ *             Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
+ *         .build();
+ * }
+ *
+ * @McpElicitation
+ * public Mono handleAsyncElicitationRequest(ElicitRequest request) {
+ *     return Mono.just(ElicitResult.builder()
+ *         .message("Generated response")
+ *         .requestedSchema(
+ *             Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
+ *         .build());
+ * }
+ * }
+ * + * @author Christian Tzolov + * @see io.modelcontextprotocol.spec.McpSchema.ElicitRequest + * @see io.modelcontextprotocol.spec.McpSchema.ElicitResult + */ +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface McpElicitation { + +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AbstractMcpElicitationMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AbstractMcpElicitationMethodCallback.java new file mode 100644 index 0000000..05e551a --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AbstractMcpElicitationMethodCallback.java @@ -0,0 +1,239 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.elicitation; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; + +import org.springaicommunity.mcp.annotation.McpElicitation; + +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.util.Assert; + +/** + * Abstract base class for creating callbacks around elicitation methods. + * + * This class provides common functionality for both synchronous and asynchronous + * elicitation method callbacks. It contains shared logic for method validation, argument + * building, and other common operations. + * + * @author Christian Tzolov + */ +public abstract class AbstractMcpElicitationMethodCallback { + + protected final Method method; + + protected final Object bean; + + /** + * Constructor for AbstractMcpElicitationMethodCallback. + * @param method The method to create a callback for + * @param bean The bean instance that contains the method + */ + protected AbstractMcpElicitationMethodCallback(Method method, Object bean) { + Assert.notNull(method, "Method can't be null!"); + Assert.notNull(bean, "Bean can't be null!"); + + this.method = method; + this.bean = bean; + this.validateMethod(this.method); + } + + /** + * Validates that the method signature is compatible with the elicitation callback. + *

+ * This method checks that the return type is valid and that the parameters match the + * expected pattern. + * @param method The method to validate + * @throws IllegalArgumentException if the method signature is not compatible + */ + protected void validateMethod(Method method) { + if (method == null) { + throw new IllegalArgumentException("Method must not be null"); + } + + this.validateReturnType(method); + this.validateParameters(method); + } + + /** + * Validates that the method return type is compatible with the elicitation callback. + * This method should be implemented by subclasses to handle specific return type + * validation. + * @param method The method to validate + * @throws IllegalArgumentException if the return type is not compatible + */ + protected abstract void validateReturnType(Method method); + + /** + * Validates method parameters. This method provides common validation logic and + * delegates exchange type checking to subclasses. + * @param method The method to validate + * @throws IllegalArgumentException if the parameters are not compatible + */ + protected void validateParameters(Method method) { + Parameter[] parameters = method.getParameters(); + + // Check parameter count - must have at least 1 parameter + if (parameters.length < 1) { + throw new IllegalArgumentException( + "Method must have at least 1 parameter (ElicitRequest): " + method.getName() + " in " + + method.getDeclaringClass().getName() + " has " + parameters.length + " parameters"); + } + + // Check parameter types + if (parameters.length == 1) { + // Single parameter must be ElicitRequest + if (!ElicitRequest.class.isAssignableFrom(parameters[0].getType())) { + throw new IllegalArgumentException("Single parameter must be of type ElicitRequest: " + method.getName() + + " in " + method.getDeclaringClass().getName() + " has parameter of type " + + parameters[0].getType().getName()); + } + } + else { + // TODO: Support for multiple parameters corresponding to ElicitRequest + // fields + // For now, we only support the single parameter version + throw new IllegalArgumentException( + "Currently only methods with a single ElicitRequest parameter are supported: " + method.getName() + + " in " + method.getDeclaringClass().getName() + " has " + parameters.length + + " parameters"); + } + } + + /** + * Builds the arguments array for invoking the method. + *

+ * This method constructs an array of arguments based on the method's parameter types + * and the available values (exchange, request). + * @param method The method to build arguments for + * @param exchange The server exchange + * @param request The elicitation request + * @return An array of arguments for the method invocation + */ + protected Object[] buildArgs(Method method, Object exchange, ElicitRequest request) { + Parameter[] parameters = method.getParameters(); + Object[] args = new Object[parameters.length]; + + if (parameters.length == 1) { + // Single parameter (ElicitRequest) + args[0] = request; + } + else { + // TODO: Support for multiple parameters corresponding to ElicitRequest + // fields + // For now, we only support the single parameter version + throw new IllegalArgumentException( + "Currently only methods with a single ElicitRequest parameter are supported"); + } + + return args; + } + + /** + * Checks if a parameter type is compatible with the exchange type. This method should + * be implemented by subclasses to handle specific exchange type checking. + * @param paramType The parameter type to check + * @return true if the parameter type is compatible with the exchange type, false + * otherwise + */ + protected abstract boolean isExchangeType(Class paramType); + + /** + * Exception thrown when there is an error invoking an elicitation method. + */ + public static class McpElicitationMethodException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Constructs a new exception with the specified detail message and cause. + * @param message The detail message + * @param cause The cause + */ + public McpElicitationMethodException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new exception with the specified detail message. + * @param message The detail message + */ + public McpElicitationMethodException(String message) { + super(message); + } + + } + + /** + * Abstract builder for creating McpElicitationMethodCallback instances. + *

+ * This builder provides a base for constructing callback instances with the required + * parameters. + * + * @param The type of the builder + * @param The type of the callback + */ + protected abstract static class AbstractBuilder, R> { + + protected Method method; + + protected Object bean; + + /** + * Set the method to create a callback for. + * @param method The method to create a callback for + * @return This builder + */ + @SuppressWarnings("unchecked") + public T method(Method method) { + this.method = method; + return (T) this; + } + + /** + * Set the bean instance that contains the method. + * @param bean The bean instance + * @return This builder + */ + @SuppressWarnings("unchecked") + public T bean(Object bean) { + this.bean = bean; + return (T) this; + } + + /** + * Set the elicitation annotation. + * @param elicitation The elicitation annotation + * @return This builder + */ + @SuppressWarnings("unchecked") + public T elicitation(McpElicitation elicitation) { + // No additional configuration needed from the annotation at this time + return (T) this; + } + + /** + * Validate the builder state. + * @throws IllegalArgumentException if the builder state is invalid + */ + protected void validate() { + if (method == null) { + throw new IllegalArgumentException("Method must not be null"); + } + if (bean == null) { + throw new IllegalArgumentException("Bean must not be null"); + } + } + + /** + * Build the callback. + * @return A new callback instance + */ + public abstract R build(); + + } + +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallback.java new file mode 100644 index 0000000..642c498 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallback.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.elicitation; + +import java.lang.reflect.Method; +import java.util.function.Function; + +import org.springaicommunity.mcp.annotation.McpElicitation; + +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import reactor.core.publisher.Mono; + +/** + * Class for creating Function callbacks around elicitation methods that return Mono. + * + * This class provides a way to convert methods annotated with {@link McpElicitation} into + * callback functions that can be used to handle elicitation requests in a reactive way. + * It supports methods with a single ElicitRequest parameter. + * + * @author Christian Tzolov + */ +public final class AsyncMcpElicitationMethodCallback extends AbstractMcpElicitationMethodCallback + implements Function> { + + private AsyncMcpElicitationMethodCallback(Builder builder) { + super(builder.method, builder.bean); + } + + /** + * Apply the callback to the given request. + *

+ * This method builds the arguments for the method call, invokes the method, and + * returns a Mono that completes with the result. + * @param request The elicitation request, must not be null + * @return A Mono that completes with the result of the method invocation + * @throws McpElicitationMethodException if there is an error invoking the elicitation + * method + * @throws IllegalArgumentException if the request is null + */ + @Override + public Mono apply(ElicitRequest request) { + if (request == null) { + return Mono.error(new IllegalArgumentException("Request must not be null")); + } + + try { + // Build arguments for the method call + Object[] args = this.buildArgs(this.method, null, request); + + // Invoke the method + this.method.setAccessible(true); + Object result = this.method.invoke(this.bean, args); + + // If the method returns a Mono, handle it + if (result instanceof Mono) { + @SuppressWarnings("unchecked") + Mono monoResult = (Mono) result; + return monoResult; + } + // If the method returns an ElicitResult directly, wrap it in a Mono + else if (result instanceof ElicitResult) { + return Mono.just((ElicitResult) result); + } + // Otherwise, throw an exception + else { + return Mono.error(new McpElicitationMethodException( + "Method must return Mono or ElicitResult: " + this.method.getName())); + } + } + catch (Exception e) { + return Mono.error(new McpElicitationMethodException( + "Error invoking elicitation method: " + this.method.getName(), e)); + } + } + + /** + * Validates that the method return type is compatible with the elicitation callback. + * @param method The method to validate + * @throws IllegalArgumentException if the return type is not compatible + */ + @Override + protected void validateReturnType(Method method) { + Class returnType = method.getReturnType(); + + if (!Mono.class.isAssignableFrom(returnType) && !ElicitResult.class.isAssignableFrom(returnType)) { + throw new IllegalArgumentException( + "Method must return Mono or ElicitResult: " + method.getName() + " in " + + method.getDeclaringClass().getName() + " returns " + returnType.getName()); + } + } + + /** + * Checks if a parameter type is compatible with the exchange type. + * @param paramType The parameter type to check + * @return true if the parameter type is compatible with the exchange type, false + * otherwise + */ + @Override + protected boolean isExchangeType(Class paramType) { + // No exchange type for elicitation methods + return false; + } + + /** + * Builder for creating AsyncMcpElicitationMethodCallback instances. + *

+ * This builder provides a fluent API for constructing + * AsyncMcpElicitationMethodCallback instances with the required parameters. + */ + public static class Builder extends AbstractBuilder { + + /** + * Build the callback. + * @return A new AsyncMcpElicitationMethodCallback instance + */ + @Override + public AsyncMcpElicitationMethodCallback build() { + validate(); + return new AsyncMcpElicitationMethodCallback(this); + } + + } + + /** + * Create a new builder. + * @return A new builder instance + */ + public static Builder builder() { + return new Builder(); + } + +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallback.java new file mode 100644 index 0000000..29961f2 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallback.java @@ -0,0 +1,119 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.elicitation; + +import java.lang.reflect.Method; +import java.util.function.Function; + +import org.springaicommunity.mcp.annotation.McpElicitation; + +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; + +/** + * Class for creating Function callbacks around elicitation methods. + * + * This class provides a way to convert methods annotated with {@link McpElicitation} into + * callback functions that can be used to handle elicitation requests. It supports methods + * with a single ElicitRequest parameter. + * + * @author Christian Tzolov + */ +public final class SyncMcpElicitationMethodCallback extends AbstractMcpElicitationMethodCallback + implements Function { + + private SyncMcpElicitationMethodCallback(Builder builder) { + super(builder.method, builder.bean); + } + + /** + * Apply the callback to the given request. + *

+ * This method builds the arguments for the method call, invokes the method, and + * returns the result. + * @param request The elicitation request, must not be null + * @return The result of the method invocation + * @throws McpElicitationMethodException if there is an error invoking the elicitation + * method + * @throws IllegalArgumentException if the request is null + */ + @Override + public ElicitResult apply(ElicitRequest request) { + if (request == null) { + throw new IllegalArgumentException("Request must not be null"); + } + + try { + // Build arguments for the method call + Object[] args = this.buildArgs(this.method, null, request); + + // Invoke the method + this.method.setAccessible(true); + Object result = this.method.invoke(this.bean, args); + + // Return the result + return (ElicitResult) result; + } + catch (Exception e) { + throw new McpElicitationMethodException("Error invoking elicitation method: " + this.method.getName(), e); + } + } + + /** + * Validates that the method return type is compatible with the elicitation callback. + * @param method The method to validate + * @throws IllegalArgumentException if the return type is not compatible + */ + @Override + protected void validateReturnType(Method method) { + Class returnType = method.getReturnType(); + + if (!ElicitResult.class.isAssignableFrom(returnType)) { + throw new IllegalArgumentException("Method must return ElicitResult: " + method.getName() + " in " + + method.getDeclaringClass().getName() + " returns " + returnType.getName()); + } + } + + /** + * Checks if a parameter type is compatible with the exchange type. + * @param paramType The parameter type to check + * @return true if the parameter type is compatible with the exchange type, false + * otherwise + */ + @Override + protected boolean isExchangeType(Class paramType) { + // No exchange type for elicitation methods + return false; + } + + /** + * Builder for creating SyncMcpElicitationMethodCallback instances. + *

+ * This builder provides a fluent API for constructing + * SyncMcpElicitationMethodCallback instances with the required parameters. + */ + public static class Builder extends AbstractBuilder { + + /** + * Build the callback. + * @return A new SyncMcpElicitationMethodCallback instance + */ + @Override + public SyncMcpElicitationMethodCallback build() { + validate(); + return new SyncMcpElicitationMethodCallback(this); + } + + } + + /** + * Create a new builder. + * @return A new builder instance + */ + public static Builder builder() { + return new Builder(); + } + +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/sampling/AbstractMcpSamplingMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/sampling/AbstractMcpSamplingMethodCallback.java index 288660c..ae2ec76 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/sampling/AbstractMcpSamplingMethodCallback.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/sampling/AbstractMcpSamplingMethodCallback.java @@ -9,9 +9,7 @@ import org.springaicommunity.mcp.annotation.McpSampling; -import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest; -import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult; import io.modelcontextprotocol.util.Assert; /** diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/AsyncMcpElicitationProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/AsyncMcpElicitationProvider.java new file mode 100644 index 0000000..5678ead --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/AsyncMcpElicitationProvider.java @@ -0,0 +1,124 @@ +/* + * 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; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.springaicommunity.mcp.annotation.McpElicitation; +import org.springaicommunity.mcp.method.elicitation.AsyncMcpElicitationMethodCallback; + +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * Provider for asynchronous elicitation callbacks. + * + *

+ * This class scans a list of objects for methods annotated with {@link McpElicitation} + * and creates {@link Function} callbacks for them. These callbacks can be used to handle + * elicitation requests from MCP servers in a reactive way. + * + *

+ * Example usage:

{@code
+ * // Create a provider with a list of objects containing @McpElicitation methods
+ * AsyncMcpElicitationProvider provider = new AsyncMcpElicitationProvider(List.of(elicitationHandler));
+ *
+ * // Get the elicitation handler
+ * Function> elicitationHandler = provider.getElicitationHandler();
+ *
+ * // Add the handler to the client features
+ * McpClientFeatures.Async clientFeatures = new McpClientFeatures.Async(
+ *     clientInfo, clientCapabilities, roots,
+ *     toolsChangeConsumers, resourcesChangeConsumers, promptsChangeConsumers,
+ *     loggingConsumers, samplingHandler, elicitationHandler);
+ * }
+ * + * @author Christian Tzolov + * @see McpElicitation + * @see AsyncMcpElicitationMethodCallback + * @see ElicitRequest + * @see ElicitResult + */ +public class AsyncMcpElicitationProvider { + + private final List elicitationObjects; + + /** + * Create a new AsyncMcpElicitationProvider. + * @param elicitationObjects the objects containing methods annotated with + * {@link McpElicitation} + */ + public AsyncMcpElicitationProvider(List elicitationObjects) { + Assert.notNull(elicitationObjects, "elicitationObjects cannot be null"); + this.elicitationObjects = elicitationObjects; + } + + /** + * Get the elicitation handler. + * @return the elicitation handler + * @throws IllegalStateException if no elicitation methods are found or if multiple + * elicitation methods are found + */ + public Function> getElicitationHandler() { + List>> elicitationHandlers = this.elicitationObjects.stream() + .map(elicitationObject -> Stream.of(doGetClassMethods(elicitationObject)) + .filter(method -> method.isAnnotationPresent(McpElicitation.class)) + .filter(method -> method.getParameterCount() == 1 + && ElicitRequest.class.isAssignableFrom(method.getParameterTypes()[0])) + .filter(method -> Mono.class.isAssignableFrom(method.getReturnType()) + || ElicitResult.class.isAssignableFrom(method.getReturnType())) + .map(mcpElicitationMethod -> { + var elicitationAnnotation = mcpElicitationMethod.getAnnotation(McpElicitation.class); + + Function> methodCallback = AsyncMcpElicitationMethodCallback + .builder() + .method(mcpElicitationMethod) + .bean(elicitationObject) + .elicitation(elicitationAnnotation) + .build(); + + return methodCallback; + }) + .toList()) + .flatMap(List::stream) + .toList(); + + if (elicitationHandlers.isEmpty()) { + throw new IllegalStateException("No elicitation methods found"); + } + if (elicitationHandlers.size() > 1) { + throw new IllegalStateException("Multiple elicitation methods found: " + elicitationHandlers.size()); + } + + return elicitationHandlers.get(0); + } + + /** + * Returns the methods of the given bean class. + * @param bean the bean instance + * @return the methods of the bean class + */ + protected Method[] doGetClassMethods(Object bean) { + return bean.getClass().getDeclaredMethods(); + } + +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/SyncMcpElicitationProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/SyncMcpElicitationProvider.java new file mode 100644 index 0000000..e845175 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/SyncMcpElicitationProvider.java @@ -0,0 +1,123 @@ +/* + * 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; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.springaicommunity.mcp.annotation.McpElicitation; +import org.springaicommunity.mcp.method.elicitation.SyncMcpElicitationMethodCallback; + +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * Provider for synchronous elicitation callbacks. + * + *

+ * This class scans a list of objects for methods annotated with {@link McpElicitation} + * and creates {@link Function} callbacks for them. These callbacks can be used to handle + * elicitation requests from MCP servers. + * + *

+ * Example usage:

{@code
+ * // Create a provider with a list of objects containing @McpElicitation methods
+ * SyncMcpElicitationProvider provider = new SyncMcpElicitationProvider(List.of(elicitationHandler));
+ *
+ * // Get the elicitation handler
+ * Function elicitationHandler = provider.getElicitationHandler();
+ *
+ * // Add the handler to the client features
+ * McpClientFeatures.Sync clientFeatures = new McpClientFeatures.Sync(
+ *     clientInfo, clientCapabilities, roots,
+ *     toolsChangeConsumers, resourcesChangeConsumers, promptsChangeConsumers,
+ *     loggingConsumers, samplingHandler, elicitationHandler);
+ * }
+ * + * @author Christian Tzolov + * @see McpElicitation + * @see SyncMcpElicitationMethodCallback + * @see ElicitRequest + * @see ElicitResult + */ +public class SyncMcpElicitationProvider { + + private final List elicitationObjects; + + /** + * Create a new SyncMcpElicitationProvider. + * @param elicitationObjects the objects containing methods annotated with + * {@link McpElicitation} + */ + public SyncMcpElicitationProvider(List elicitationObjects) { + Assert.notNull(elicitationObjects, "elicitationObjects cannot be null"); + this.elicitationObjects = elicitationObjects; + } + + /** + * Get the elicitation handler. + * @return the elicitation handler + * @throws IllegalStateException if no elicitation methods are found or if multiple + * elicitation methods are found + */ + public Function getElicitationHandler() { + List> elicitationHandlers = this.elicitationObjects.stream() + .map(elicitationObject -> Stream.of(doGetClassMethods(elicitationObject)) + .filter(method -> method.isAnnotationPresent(McpElicitation.class)) + .filter(method -> !Mono.class.isAssignableFrom(method.getReturnType())) + .filter(method -> ElicitResult.class.isAssignableFrom(method.getReturnType())) + .filter(method -> method.getParameterCount() == 1 + && ElicitRequest.class.isAssignableFrom(method.getParameterTypes()[0])) + .map(mcpElicitationMethod -> { + var elicitationAnnotation = mcpElicitationMethod.getAnnotation(McpElicitation.class); + + Function methodCallback = SyncMcpElicitationMethodCallback.builder() + .method(mcpElicitationMethod) + .bean(elicitationObject) + .elicitation(elicitationAnnotation) + .build(); + + return methodCallback; + }) + .toList()) + .flatMap(List::stream) + .toList(); + + if (elicitationHandlers.isEmpty()) { + throw new IllegalStateException("No elicitation methods found"); + } + if (elicitationHandlers.size() > 1) { + throw new IllegalStateException("Multiple elicitation methods found: " + elicitationHandlers.size()); + } + + return elicitationHandlers.get(0); + } + + /** + * Returns the methods of the given bean class. + * @param bean the bean instance + * @return the methods of the bean class + */ + protected Method[] doGetClassMethods(Object bean) { + return bean.getClass().getDeclaredMethods(); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallbackExample.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallbackExample.java new file mode 100644 index 0000000..2e4d7eb --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallbackExample.java @@ -0,0 +1,49 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.elicitation; + +import java.util.Map; + +import org.springaicommunity.mcp.annotation.McpElicitation; + +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import reactor.core.publisher.Mono; + +/** + * Example class demonstrating asynchronous elicitation method usage. + * + * @author Christian Tzolov + */ +public class AsyncMcpElicitationMethodCallbackExample { + + @McpElicitation + public Mono handleElicitationRequest(ElicitRequest request) { + // Example implementation that accepts the request and returns some content + return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("userInput", "Example async user input", + "confirmed", true, "timestamp", System.currentTimeMillis()))); + } + + @McpElicitation + public Mono handleDeclineElicitationRequest(ElicitRequest request) { + // Example implementation that declines the request after a delay + return Mono.delay(java.time.Duration.ofMillis(100)) + .then(Mono.just(new ElicitResult(ElicitResult.Action.DECLINE, null))); + } + + @McpElicitation + public ElicitResult handleSyncElicitationRequest(ElicitRequest request) { + // Example implementation that returns synchronously but will be wrapped in Mono + return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("syncResponse", + "This was returned synchronously but wrapped in Mono", "requestMessage", request.message())); + } + + @McpElicitation + public Mono handleCancelElicitationRequest(ElicitRequest request) { + // Example implementation that cancels the request + return Mono.just(new ElicitResult(ElicitResult.Action.CANCEL, null)); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallbackExample.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallbackExample.java new file mode 100644 index 0000000..a3a6d66 --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallbackExample.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.elicitation; + +import java.util.Map; + +import org.springaicommunity.mcp.annotation.McpElicitation; + +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; + +/** + * Example class demonstrating synchronous elicitation method usage. + * + * @author Christian Tzolov + */ +public class SyncMcpElicitationMethodCallbackExample { + + @McpElicitation + public ElicitResult handleElicitationRequest(ElicitRequest request) { + // Example implementation that accepts the request and returns some content + return new ElicitResult(ElicitResult.Action.ACCEPT, + Map.of("userInput", "Example user input", "confirmed", true)); + } + + @McpElicitation + public ElicitResult handleDeclineElicitationRequest(ElicitRequest request) { + // Example implementation that declines the request + return new ElicitResult(ElicitResult.Action.DECLINE, null); + } + + @McpElicitation + public ElicitResult handleCancelElicitationRequest(ElicitRequest request) { + // Example implementation that cancels the request + return new ElicitResult(ElicitResult.Action.CANCEL, null); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/AsyncMcpElicitationProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/AsyncMcpElicitationProviderTests.java new file mode 100644 index 0000000..326a696 --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/AsyncMcpElicitationProviderTests.java @@ -0,0 +1,116 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.provider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpElicitation; + +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests for {@link AsyncMcpElicitationProvider}. + * + * @author Christian Tzolov + */ +public class AsyncMcpElicitationProviderTests { + + @Test + public void testGetElicitationHandler() { + var provider = new AsyncMcpElicitationProvider(List.of(new TestElicitationHandler())); + Function> handler = provider.getElicitationHandler(); + + assertNotNull(handler); + + ElicitRequest request = new ElicitRequest("Please provide your name", + Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string")))); + Mono result = handler.apply(request); + + StepVerifier.create(result).assertNext(elicitResult -> { + assertEquals(ElicitResult.Action.ACCEPT, elicitResult.action()); + assertNotNull(elicitResult.content()); + assertEquals("Async Test User", elicitResult.content().get("name")); + }).verifyComplete(); + } + + @Test + public void testGetElicitationHandlerWithSyncMethod() { + var provider = new AsyncMcpElicitationProvider(List.of(new SyncElicitationHandler())); + Function> handler = provider.getElicitationHandler(); + + assertNotNull(handler); + + ElicitRequest request = new ElicitRequest("Please provide your name", + Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string")))); + Mono result = handler.apply(request); + + StepVerifier.create(result).assertNext(elicitResult -> { + assertEquals(ElicitResult.Action.ACCEPT, elicitResult.action()); + assertNotNull(elicitResult.content()); + assertEquals("Sync Test User", elicitResult.content().get("name")); + }).verifyComplete(); + } + + @Test + public void testNoElicitationMethods() { + var provider = new AsyncMcpElicitationProvider(List.of(new Object())); + + assertThrows(IllegalStateException.class, () -> provider.getElicitationHandler(), + "No elicitation methods found"); + } + + @Test + public void testMultipleElicitationMethods() { + var provider = new AsyncMcpElicitationProvider(List.of(new MultipleElicitationHandler())); + + assertThrows(IllegalStateException.class, () -> provider.getElicitationHandler(), + "Multiple elicitation methods found"); + } + + public static class TestElicitationHandler { + + @McpElicitation + public Mono handleElicitation(ElicitRequest request) { + return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, + Map.of("name", "Async Test User", "message", request.message()))); + } + + } + + public static class SyncElicitationHandler { + + @McpElicitation + public ElicitResult handleElicitation(ElicitRequest request) { + return new ElicitResult(ElicitResult.Action.ACCEPT, + Map.of("name", "Sync Test User", "message", request.message())); + } + + } + + public static class MultipleElicitationHandler { + + @McpElicitation + public Mono handleElicitation1(ElicitRequest request) { + return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("handler", "1"))); + } + + @McpElicitation + public Mono handleElicitation2(ElicitRequest request) { + return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("handler", "2"))); + } + + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/SyncMcpElicitationProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/SyncMcpElicitationProviderTests.java new file mode 100644 index 0000000..e8c1f7f --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/SyncMcpElicitationProviderTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.provider; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpElicitation; + +import io.modelcontextprotocol.spec.McpSchema.ElicitRequest; +import io.modelcontextprotocol.spec.McpSchema.ElicitResult; + +/** + * Tests for {@link SyncMcpElicitationProvider}. + * + * @author Christian Tzolov + */ +public class SyncMcpElicitationProviderTests { + + @Test + public void testGetElicitationHandler() { + var provider = new SyncMcpElicitationProvider(List.of(new TestElicitationHandler())); + Function handler = provider.getElicitationHandler(); + + assertNotNull(handler); + + ElicitRequest request = new ElicitRequest("Please provide your name", + Map.of("type", "object", "properties", Map.of("name", Map.of("type", "string")))); + ElicitResult result = handler.apply(request); + + assertNotNull(result); + assertEquals(ElicitResult.Action.ACCEPT, result.action()); + assertNotNull(result.content()); + assertEquals("Test User", result.content().get("name")); + } + + @Test + public void testNoElicitationMethods() { + var provider = new SyncMcpElicitationProvider(List.of(new Object())); + + assertThrows(IllegalStateException.class, () -> provider.getElicitationHandler(), + "No elicitation methods found"); + } + + @Test + public void testMultipleElicitationMethods() { + var provider = new SyncMcpElicitationProvider(List.of(new MultipleElicitationHandler())); + + assertThrows(IllegalStateException.class, () -> provider.getElicitationHandler(), + "Multiple elicitation methods found"); + } + + public static class TestElicitationHandler { + + @McpElicitation + public ElicitResult handleElicitation(ElicitRequest request) { + return new ElicitResult(ElicitResult.Action.ACCEPT, + Map.of("name", "Test User", "message", request.message())); + } + + } + + public static class MultipleElicitationHandler { + + @McpElicitation + public ElicitResult handleElicitation1(ElicitRequest request) { + return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("handler", "1")); + } + + @McpElicitation + public ElicitResult handleElicitation2(ElicitRequest request) { + return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("handler", "2")); + } + + } + +} diff --git a/pom.xml b/pom.xml index 5d5b866..e20ff1d 100644 --- a/pom.xml +++ b/pom.xml @@ -55,7 +55,7 @@ 17 17 - 0.11.0-SNAPSHOT + 0.12.0-SNAPSHOT 1.1.0-SNAPSHOT 2.0.16