diff --git a/README.md b/README.md index 4c3af09..bb78526 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ The Spring integration module provides seamless integration with Spring AI and S - **`@McpSampling`** - Annotates methods that handle sampling requests from MCP servers - **`@McpElicitation`** - Annotates methods that handle elicitation requests to gather additional information from users - **`@McpProgress`** - Annotates methods that handle progress notifications for long-running operations +- **`@McpToolListChanged`** - Annotates methods that handle tool list change notifications from MCP servers #### Server - **`@McpComplete`** - Annotates methods that provide completion functionality for prompts or URI templates @@ -182,6 +183,11 @@ The modules provide callback implementations for each operation type: - `SyncMcpProgressMethodCallback` - Synchronous implementation - `AsyncMcpProgressMethodCallback` - Asynchronous implementation using Reactor's Mono +#### Tool List Changed +- `AbstractMcpToolListChangedMethodCallback` - Base class for tool list changed method callbacks +- `SyncMcpToolListChangedMethodCallback` - Synchronous implementation +- `AsyncMcpToolListChangedMethodCallback` - Asynchronous implementation using Reactor's Mono + ### Providers The project includes provider classes that scan for annotated methods and create appropriate callbacks: @@ -200,6 +206,8 @@ The project includes provider classes that scan for annotated methods and create - `AsyncMcpElicitationProvider` - Processes `@McpElicitation` annotations for asynchronous operations - `SyncMcpProgressProvider` - Processes `@McpProgress` annotations for synchronous operations - `AsyncMcpProgressProvider` - Processes `@McpProgress` annotations for asynchronous operations +- `SyncMcpToolListChangedProvider` - Processes `@McpToolListChanged` annotations for synchronous operations +- `AsyncMcpToolListChangedProvider` - Processes `@McpToolListChanged` annotations for asynchronous operations #### Stateless Providers (using McpTransportContext) - `SyncStatelessMcpCompleteProvider` - Processes `@McpComplete` annotations for synchronous stateless operations @@ -1189,6 +1197,124 @@ public class MyMcpClient { } ``` +### Mcp Client Tool List Changed Example + +```java +public class ToolListChangedHandler { + + /** + * Handle tool list change notifications with a single parameter. + * @param updatedTools The updated list of tools after the change + */ + @McpToolListChanged + public void handleToolListChanged(List updatedTools) { + System.out.println("Tool list updated, now contains " + updatedTools.size() + " tools:"); + for (McpSchema.Tool tool : updatedTools) { + System.out.println(" - " + tool.name() + ": " + tool.description()); + } + } + + /** + * Handle tool list change notifications for a specific client. + * @param updatedTools The updated list of tools after the change + */ + @McpToolListChanged(clientId = "client-1") + public void handleClient1ToolListChanged(List updatedTools) { + System.out.println("Client-1 tool list updated with " + updatedTools.size() + " tools"); + // Process the updated tool list for client-1 + updateClientToolCache("client-1", updatedTools); + } + + /** + * Handle tool list change notifications for another specific client. + * @param updatedTools The updated list of tools after the change + */ + @McpToolListChanged(clientId = "client-2") + public void handleClient2ToolListChanged(List updatedTools) { + System.out.println("Client-2 tool list updated with " + updatedTools.size() + " tools"); + // Process the updated tool list for client-2 + updateClientToolCache("client-2", updatedTools); + } + + private void updateClientToolCache(String clientId, List tools) { + // Implementation to update tool cache for specific client + System.out.println("Updated tool cache for " + clientId + " with " + tools.size() + " tools"); + } +} + +public class AsyncToolListChangedHandler { + + /** + * Handle tool list change notifications asynchronously. + * @param updatedTools The updated list of tools after the change + * @return A Mono that completes when the notification is handled + */ + @McpToolListChanged + public Mono handleAsyncToolListChanged(List updatedTools) { + return Mono.fromRunnable(() -> { + System.out.println("Async tool list update: " + updatedTools.size() + " tools"); + // Process the updated tool list asynchronously + processToolListUpdate(updatedTools); + }); + } + + /** + * Handle tool list change notifications for a specific client asynchronously. + * @param updatedTools The updated list of tools after the change + * @return A Mono that completes when the notification is handled + */ + @McpToolListChanged(clientId = "client-2") + public Mono handleClient2AsyncToolListChanged(List updatedTools) { + return Mono.fromRunnable(() -> { + System.out.println("Client-2 async tool list update: " + updatedTools.size() + " tools"); + // Process the updated tool list for client-2 asynchronously + processClientToolListUpdate("client-2", updatedTools); + }).then(); + } + + private void processToolListUpdate(List tools) { + // Implementation to process tool list update + System.out.println("Processing tool list update with " + tools.size() + " tools"); + } + + private void processClientToolListUpdate(String clientId, List tools) { + // Implementation to process tool list update for specific client + System.out.println("Processing tool list update for " + clientId + " with " + tools.size() + " tools"); + } +} + +public class MyMcpClient { + + public static McpSyncClient createSyncClientWithToolListChanged(ToolListChangedHandler toolListChangedHandler) { + List>> toolListChangedConsumers = + new SyncMcpToolListChangedProvider(List.of(toolListChangedHandler)).getToolListChangedConsumers(); + + McpSyncClient client = McpClient.sync(transport) + .capabilities(ClientCapabilities.builder() + // Enable capabilities... + .build()) + .toolListChangedConsumers(toolListChangedConsumers) + .build(); + + return client; + } + + public static McpAsyncClient createAsyncClientWithToolListChanged(AsyncToolListChangedHandler asyncToolListChangedHandler) { + List, Mono>> toolListChangedHandlers = + new AsyncMcpToolListChangedProvider(List.of(asyncToolListChangedHandler)).getToolListChangedHandlers(); + + McpAsyncClient client = McpClient.async(transport) + .capabilities(ClientCapabilities.builder() + // Enable capabilities... + .build()) + .toolListChangedHandlers(toolListChangedHandlers) + .build(); + + return client; + } +} +``` + ### Mcp Client Elicitation Example ```java @@ -1607,6 +1733,18 @@ public class McpConfig { return SpringAiMcpAnnotationProvider.createAsyncProgressSpecifications(asyncProgressHandlers); } + @Bean + public List syncToolListChangedSpecifications( + List toolListChangedHandlers) { + return SpringAiMcpAnnotationProvider.createSyncToolListChangedSpecifications(toolListChangedHandlers); + } + + @Bean + public List asyncToolListChangedSpecifications( + List asyncToolListChangedHandlers) { + return SpringAiMcpAnnotationProvider.createAsyncToolListChangedSpecifications(asyncToolListChangedHandlers); + } + // Stateless Spring Integration Examples @Bean @@ -1643,6 +1781,7 @@ public class McpConfig { - **Logging consumer support** - Handle logging message notifications from MCP servers - **Sampling support** - Handle sampling requests from MCP servers - **Progress notification support** - Handle progress notifications for long-running operations +- **Tool list changed support** - Handle tool list change notifications from MCP servers when tools are dynamically added, removed, or modified - **Spring integration** - Seamless integration with Spring Framework and Spring AI, including support for both stateful and stateless operations - **AOP proxy support** - Proper handling of Spring AOP proxies when processing annotations 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 c4c1659..bf279a4 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 @@ -18,10 +18,12 @@ import java.lang.reflect.Method; import java.util.List; +import org.springaicommunity.mcp.method.changed.tool.AsyncToolListChangedSpecification; import org.springaicommunity.mcp.method.elicitation.AsyncElicitationSpecification; import org.springaicommunity.mcp.method.logging.AsyncLoggingSpecification; import org.springaicommunity.mcp.method.progress.AsyncProgressSpecification; import org.springaicommunity.mcp.method.sampling.AsyncSamplingSpecification; +import org.springaicommunity.mcp.provider.changed.tool.AsyncMcpToolListChangedProvider; import org.springaicommunity.mcp.provider.elicitation.AsyncMcpElicitationProvider; import org.springaicommunity.mcp.provider.logging.AsyncMcpLoggingProvider; import org.springaicommunity.mcp.provider.progress.AsyncMcpProgressProvider; @@ -143,6 +145,19 @@ protected Method[] doGetClassMethods(Object bean) { } + private static class SpringAiAsyncMcpToolListChangedProvider extends AsyncMcpToolListChangedProvider { + + public SpringAiAsyncMcpToolListChangedProvider(List toolListChangedObjects) { + super(toolListChangedObjects); + } + + @Override + protected Method[] doGetClassMethods(Object bean) { + return AnnotationProviderUtil.beanMethods(bean); + } + + } + public static List createAsyncLoggingSpecifications(List loggingObjects) { return new SpringAiAsyncMcpLoggingProvider(loggingObjects).getLoggingSpecifications(); } @@ -179,4 +194,9 @@ public static List createAsyncProgressSpecifications return new SpringAiAsyncMcpProgressProvider(progressObjects).getProgressSpecifications(); } + public static List createAsyncToolListChangedSpecifications( + List toolListChangedObjects) { + return new SpringAiAsyncMcpToolListChangedProvider(toolListChangedObjects).getToolListChangedSpecifications(); + } + } 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 7a57144..9af824a 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 @@ -18,10 +18,12 @@ import java.lang.reflect.Method; import java.util.List; +import org.springaicommunity.mcp.method.changed.tool.SyncToolListChangedSpecification; import org.springaicommunity.mcp.method.elicitation.SyncElicitationSpecification; import org.springaicommunity.mcp.method.logging.SyncLoggingSpecification; import org.springaicommunity.mcp.method.progress.SyncProgressSpecification; import org.springaicommunity.mcp.method.sampling.SyncSamplingSpecification; +import org.springaicommunity.mcp.provider.changed.tool.SyncMcpToolListChangedProvider; import org.springaicommunity.mcp.provider.complete.SyncMcpCompletionProvider; import org.springaicommunity.mcp.provider.elicitation.SyncMcpElicitationProvider; import org.springaicommunity.mcp.provider.logging.SyncMcpLogginProvider; @@ -188,6 +190,19 @@ protected Method[] doGetClassMethods(Object bean) { } + private static class SpringAiSyncMcpToolListChangedProvider extends SyncMcpToolListChangedProvider { + + public SpringAiSyncMcpToolListChangedProvider(List toolListChangedObjects) { + super(toolListChangedObjects); + } + + @Override + protected Method[] doGetClassMethods(Object bean) { + return AnnotationProviderUtil.beanMethods(bean); + } + + } + public static List createSyncToolSpecifications(List toolObjects) { return new SpringAiSyncToolProvider(toolObjects).getToolSpecifications(); } @@ -236,4 +251,9 @@ public static List createSyncProgressSpecifications(L return new SpringAiSyncMcpProgressProvider(progressObjects).getProgressSpecifications(); } + public static List createSyncToolListChangedSpecifications( + List toolListChangedObjects) { + return new SpringAiSyncMcpToolListChangedProvider(toolListChangedObjects).getToolListChangedSpecifications(); + } + } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpToolListChanged.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpToolListChanged.java new file mode 100644 index 0000000..336150a --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpToolListChanged.java @@ -0,0 +1,64 @@ +/* + * 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 tool list change notifications from MCP servers. + * This annotation is applicable only for MCP clients. + * + *

+ * Methods annotated with this annotation are used to listen for notifications when the + * list of available tools changes on an MCP server. According to the MCP specification, + * servers that declare the {@code listChanged} capability will send notifications when + * their tool list is modified. + * + *

+ * The annotated method must have a void return type for synchronous consumers, or can + * return {@code Mono} for asynchronous consumers. The method should accept a single + * parameter of type {@code List} that represents the updated list of + * tools after the change notification. + * + *

+ * Example usage:

{@code
+ * @McpToolListChanged
+ * public void onToolListChanged(List updatedTools) {
+ *     // Handle tool list change notification with the updated tools
+ *     logger.info("Tool list updated, now contains {} tools", updatedTools.size());
+ *     // Process the updated tool list
+ * }
+ *
+ * @McpToolListChanged
+ * public Mono onToolListChangedAsync(List updatedTools) {
+ *     // Handle tool list change notification asynchronously
+ *     return processUpdatedTools(updatedTools);
+ * }
+ * }
+ * + * @author Christian Tzolov + * @see MCP + * Tool List Changed Notification + */ +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface McpToolListChanged { + + /** + * Used as connection or client identifier to select the MCP client that the tool + * change listener is associated with. If not specified, the listener is applied to + * all clients and will receive notifications from any connected MCP server that + * supports tool list change notifications. + * @return the client identifier, or empty string to listen to all clients + */ + String clientId() default ""; + +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/AbstractMcpToolListChangedMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/AbstractMcpToolListChangedMethodCallback.java new file mode 100644 index 0000000..8f820f7 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/AbstractMcpToolListChangedMethodCallback.java @@ -0,0 +1,211 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.tool; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.List; + +import org.springaicommunity.mcp.annotation.McpToolListChanged; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; + +/** + * Abstract base class for creating callbacks around tool list changed consumer methods. + * + * This class provides common functionality for both synchronous and asynchronous tool + * list changed consumer method callbacks. It contains shared logic for method validation, + * argument building, and other common operations. + * + * @author Christian Tzolov + */ +public abstract class AbstractMcpToolListChangedMethodCallback { + + protected final Method method; + + protected final Object bean; + + /** + * Constructor for AbstractMcpToolListChangedMethodCallback. + * @param method The method to create a callback for + * @param bean The bean instance that contains the method + */ + protected AbstractMcpToolListChangedMethodCallback(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 tool list changed + * consumer 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 tool list changed + * consumer 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. + * @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 exactly 1 parameter + if (parameters.length != 1) { + throw new IllegalArgumentException( + "Method must have exactly 1 parameter (List): " + method.getName() + " in " + + method.getDeclaringClass().getName() + " has " + parameters.length + " parameters"); + } + + // Check parameter type - must be List + Class paramType = parameters[0].getType(); + if (!List.class.isAssignableFrom(paramType)) { + throw new IllegalArgumentException("Parameter must be of type List: " + method.getName() + + " in " + method.getDeclaringClass().getName() + " has parameter of type " + paramType.getName()); + } + } + + /** + * 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. + * @param method The method to build arguments for + * @param exchange The server exchange + * @param updatedTools The updated list of tools + * @return An array of arguments for the method invocation + */ + protected Object[] buildArgs(Method method, Object exchange, List updatedTools) { + Parameter[] parameters = method.getParameters(); + Object[] args = new Object[parameters.length]; + + // Single parameter (List) + args[0] = updatedTools; + + return args; + } + + /** + * Exception thrown when there is an error invoking a tool list changed consumer + * method. + */ + public static class McpToolListChangedConsumerMethodException 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 McpToolListChangedConsumerMethodException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new exception with the specified detail message. + * @param message The detail message + */ + public McpToolListChangedConsumerMethodException(String message) { + super(message); + } + + } + + /** + * Abstract builder for creating McpToolListChangedMethodCallback 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 tool list changed annotation. + * @param toolListChanged The tool list changed annotation + * @return This builder + */ + @SuppressWarnings("unchecked") + public T toolListChanged(McpToolListChanged toolListChanged) { + // 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/changed/tool/AsyncMcpToolListChangedMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/AsyncMcpToolListChangedMethodCallback.java new file mode 100644 index 0000000..87255c2 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/AsyncMcpToolListChangedMethodCallback.java @@ -0,0 +1,128 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.tool; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Function; + +import org.springaicommunity.mcp.annotation.McpToolListChanged; + +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; + +/** + * Class for creating Function callbacks around tool list changed consumer methods that + * return Mono. + * + * This class provides a way to convert methods annotated with {@link McpToolListChanged} + * into callback functions that can be used to handle tool list change notifications in a + * reactive way. It supports methods with a single List<McpSchema.Tool> parameter. + * + * @author Christian Tzolov + */ +public final class AsyncMcpToolListChangedMethodCallback extends AbstractMcpToolListChangedMethodCallback + implements Function, Mono> { + + private AsyncMcpToolListChangedMethodCallback(Builder builder) { + super(builder.method, builder.bean); + } + + /** + * Apply the callback to the given tool list. + *

+ * This method builds the arguments for the method call, invokes the method, and + * returns a Mono that completes when the method execution is done. + * @param updatedTools The updated list of tools, must not be null + * @return A Mono that completes when the method execution is done + * @throws McpToolListChangedConsumerMethodException if there is an error invoking the + * tool list changed consumer method + * @throws IllegalArgumentException if the updatedTools is null + */ + @Override + public Mono apply(List updatedTools) { + if (updatedTools == null) { + return Mono.error(new IllegalArgumentException("Updated tools list must not be null")); + } + + try { + // Build arguments for the method call + Object[] args = this.buildArgs(this.method, null, updatedTools); + + // 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) { + // We need to handle the case where the Mono is not a Mono + // This is expected by the test testInvalidMonoReturnType + Mono monoResult = (Mono) result; + + // Convert the Mono to a Mono by checking the value + // If the value is not null (i.e., not Void), throw a ClassCastException + return monoResult.flatMap(value -> { + if (value != null) { + // This will be caught by the test testInvalidMonoReturnType + throw new ClassCastException( + "Expected Mono but got Mono<" + value.getClass().getName() + ">"); + } + return Mono.empty(); + }).then(); + } + // If the method returns void, return an empty Mono + return Mono.empty(); + } + catch (Exception e) { + return Mono.error(new McpToolListChangedConsumerMethodException( + "Error invoking tool list changed consumer method: " + this.method.getName(), e)); + } + } + + /** + * Validates that the method return type is compatible with the tool list changed + * consumer 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 (returnType != void.class && !Mono.class.isAssignableFrom(returnType)) { + throw new IllegalArgumentException("Method must have void or Mono return type: " + method.getName() + + " in " + method.getDeclaringClass().getName() + " returns " + returnType.getName()); + } + } + + /** + * Builder for creating AsyncMcpToolListChangedMethodCallback instances. + *

+ * This builder provides a fluent API for constructing + * AsyncMcpToolListChangedMethodCallback instances with the required parameters. + */ + public static class Builder extends AbstractBuilder { + + /** + * Build the callback. + * @return A new AsyncMcpToolListChangedMethodCallback instance + */ + @Override + public AsyncMcpToolListChangedMethodCallback build() { + validate(); + return new AsyncMcpToolListChangedMethodCallback(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/changed/tool/AsyncToolListChangedSpecification.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/AsyncToolListChangedSpecification.java new file mode 100644 index 0000000..98b9559 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/AsyncToolListChangedSpecification.java @@ -0,0 +1,15 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.tool; + +import java.util.List; +import java.util.function.Function; + +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; + +public record AsyncToolListChangedSpecification(String clientId, + Function, Mono> toolListChangeHandler) { +} \ No newline at end of file diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/SyncMcpToolListChangedMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/SyncMcpToolListChangedMethodCallback.java new file mode 100644 index 0000000..633f322 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/SyncMcpToolListChangedMethodCallback.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.tool; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Consumer; + +import org.springaicommunity.mcp.annotation.McpToolListChanged; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Class for creating Consumer callbacks around tool list changed consumer methods. + * + * This class provides a way to convert methods annotated with {@link McpToolListChanged} + * into callback functions that can be used to handle tool list change notifications. It + * supports methods with a single List<McpSchema.Tool> parameter. + * + * @author Christian Tzolov + */ +public final class SyncMcpToolListChangedMethodCallback extends AbstractMcpToolListChangedMethodCallback + implements Consumer> { + + private SyncMcpToolListChangedMethodCallback(Builder builder) { + super(builder.method, builder.bean); + } + + /** + * Accept the tool list change notification and process it. + *

+ * This method builds the arguments for the method call and invokes the method. + * @param updatedTools The updated list of tools, must not be null + * @throws McpToolListChangedConsumerMethodException if there is an error invoking the + * tool list changed consumer method + * @throws IllegalArgumentException if the updatedTools is null + */ + @Override + public void accept(List updatedTools) { + if (updatedTools == null) { + throw new IllegalArgumentException("Updated tools list must not be null"); + } + + try { + // Build arguments for the method call + Object[] args = this.buildArgs(this.method, null, updatedTools); + + // Invoke the method + this.method.setAccessible(true); + this.method.invoke(this.bean, args); + } + catch (Exception e) { + throw new McpToolListChangedConsumerMethodException( + "Error invoking tool list changed consumer method: " + this.method.getName(), e); + } + } + + /** + * Validates that the method return type is compatible with the tool list changed + * consumer 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 (returnType != void.class) { + throw new IllegalArgumentException("Method must have void return type: " + method.getName() + " in " + + method.getDeclaringClass().getName() + " returns " + returnType.getName()); + } + } + + /** + * Builder for creating SyncMcpToolListChangedMethodCallback instances. + *

+ * This builder provides a fluent API for constructing + * SyncMcpToolListChangedMethodCallback instances with the required parameters. + */ + public static class Builder extends AbstractBuilder { + + /** + * Build the callback. + * @return A new SyncMcpToolListChangedMethodCallback instance + */ + @Override + public SyncMcpToolListChangedMethodCallback build() { + validate(); + return new SyncMcpToolListChangedMethodCallback(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/changed/tool/SyncToolListChangedSpecification.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/SyncToolListChangedSpecification.java new file mode 100644 index 0000000..ed074ca --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/tool/SyncToolListChangedSpecification.java @@ -0,0 +1,13 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.tool; + +import java.util.List; +import java.util.function.Consumer; + +import io.modelcontextprotocol.spec.McpSchema; + +public record SyncToolListChangedSpecification(String clientId, Consumer> toolListChangeHandler) { +} \ No newline at end of file diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/tool/AsyncMcpToolListChangedProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/tool/AsyncMcpToolListChangedProvider.java new file mode 100644 index 0000000..2f7467b --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/tool/AsyncMcpToolListChangedProvider.java @@ -0,0 +1,114 @@ +/* + * 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.changed.tool; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.springaicommunity.mcp.annotation.McpToolListChanged; +import org.springaicommunity.mcp.method.changed.tool.AsyncToolListChangedSpecification; +import org.springaicommunity.mcp.method.changed.tool.AsyncMcpToolListChangedMethodCallback; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * Provider for asynchronous tool list changed consumer callbacks. + * + *

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

+ * Example usage:

{@code
+ * // Create a provider with a list of objects containing @McpToolListChanged methods
+ * AsyncMcpToolListChangedProvider provider = new AsyncMcpToolListChangedProvider(List.of(toolListHandler));
+ *
+ * // Get the list of tool list changed consumer callbacks
+ * List specifications = provider.getToolListChangedSpecifications();
+ *
+ * // Add the consumers to the client features
+ * McpClientFeatures.Async clientFeatures = new McpClientFeatures.Async(
+ *     clientInfo, clientCapabilities, roots,
+ *     toolsChangeConsumers, resourcesChangeConsumers, promptsChangeConsumers,
+ *     loggingConsumers, samplingHandler);
+ * }
+ * + * @author Christian Tzolov + * @see McpToolListChanged + * @see AsyncMcpToolListChangedMethodCallback + * @see AsyncToolListChangedSpecification + */ +public class AsyncMcpToolListChangedProvider { + + private final List toolListChangedConsumerObjects; + + /** + * Create a new AsyncMcpToolListChangedProvider. + * @param toolListChangedConsumerObjects the objects containing methods annotated with + * {@link McpToolListChanged} + */ + public AsyncMcpToolListChangedProvider(List toolListChangedConsumerObjects) { + Assert.notNull(toolListChangedConsumerObjects, "toolListChangedConsumerObjects cannot be null"); + this.toolListChangedConsumerObjects = toolListChangedConsumerObjects; + } + + /** + * Get the list of tool list changed consumer specifications. + * @return the list of tool list changed consumer specifications + */ + public List getToolListChangedSpecifications() { + + List toolListChangedConsumers = this.toolListChangedConsumerObjects.stream() + .map(consumerObject -> Stream.of(doGetClassMethods(consumerObject)) + .filter(method -> method.isAnnotationPresent(McpToolListChanged.class)) + .filter(method -> method.getReturnType() == void.class + || Mono.class.isAssignableFrom(method.getReturnType())) + .map(mcpToolListChangedConsumerMethod -> { + var toolListChangedAnnotation = mcpToolListChangedConsumerMethod + .getAnnotation(McpToolListChanged.class); + + Function, Mono> methodCallback = AsyncMcpToolListChangedMethodCallback + .builder() + .method(mcpToolListChangedConsumerMethod) + .bean(consumerObject) + .build(); + + return new AsyncToolListChangedSpecification(toolListChangedAnnotation.clientId(), methodCallback); + }) + .toList()) + .flatMap(List::stream) + .toList(); + + return toolListChangedConsumers; + } + + /** + * 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/changed/tool/SyncMcpToolListChangedProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/tool/SyncMcpToolListChangedProvider.java new file mode 100644 index 0000000..07db6a9 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/tool/SyncMcpToolListChangedProvider.java @@ -0,0 +1,112 @@ +/* + * 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.changed.tool; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import org.springaicommunity.mcp.annotation.McpToolListChanged; +import org.springaicommunity.mcp.method.changed.tool.SyncToolListChangedSpecification; +import org.springaicommunity.mcp.method.changed.tool.SyncMcpToolListChangedMethodCallback; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * Provider for synchronous tool list changed consumer callbacks. + * + *

+ * This class scans a list of objects for methods annotated with + * {@link McpToolListChanged} and creates {@link Consumer} callbacks for them. These + * callbacks can be used to handle tool list change notifications from MCP servers. + * + *

+ * Example usage:

{@code
+ * // Create a provider with a list of objects containing @McpToolListChanged methods
+ * SyncMcpToolListChangedProvider provider = new SyncMcpToolListChangedProvider(List.of(toolListHandler));
+ *
+ * // Get the list of tool list changed consumer callbacks
+ * List specifications = provider.getToolListChangedSpecifications();
+ *
+ * // Add the consumers to the client features
+ * McpClientFeatures.Sync clientFeatures = new McpClientFeatures.Sync(
+ *     clientInfo, clientCapabilities, roots,
+ *     toolsChangeConsumers, resourcesChangeConsumers, promptsChangeConsumers,
+ *     loggingConsumers, samplingHandler);
+ * }
+ * + * @author Christian Tzolov + * @see McpToolListChanged + * @see SyncMcpToolListChangedMethodCallback + * @see SyncToolListChangedSpecification + */ +public class SyncMcpToolListChangedProvider { + + private final List toolListChangedConsumerObjects; + + /** + * Create a new SyncMcpToolListChangedProvider. + * @param toolListChangedConsumerObjects the objects containing methods annotated with + * {@link McpToolListChanged} + */ + public SyncMcpToolListChangedProvider(List toolListChangedConsumerObjects) { + Assert.notNull(toolListChangedConsumerObjects, "toolListChangedConsumerObjects cannot be null"); + this.toolListChangedConsumerObjects = toolListChangedConsumerObjects; + } + + /** + * Get the list of tool list changed consumer specifications. + * @return the list of tool list changed consumer specifications + */ + public List getToolListChangedSpecifications() { + + List toolListChangedConsumers = this.toolListChangedConsumerObjects.stream() + .map(consumerObject -> Stream.of(doGetClassMethods(consumerObject)) + .filter(method -> method.isAnnotationPresent(McpToolListChanged.class)) + .filter(method -> !Mono.class.isAssignableFrom(method.getReturnType())) + .map(mcpToolListChangedConsumerMethod -> { + var toolListChangedAnnotation = mcpToolListChangedConsumerMethod + .getAnnotation(McpToolListChanged.class); + + Consumer> methodCallback = SyncMcpToolListChangedMethodCallback.builder() + .method(mcpToolListChangedConsumerMethod) + .bean(consumerObject) + .toolListChanged(toolListChangedAnnotation) + .build(); + + return new SyncToolListChangedSpecification(toolListChangedAnnotation.clientId(), methodCallback); + }) + .toList()) + .flatMap(List::stream) + .toList(); + + return toolListChangedConsumers; + } + + /** + * 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/changed/tool/AsyncMcpToolListChangedMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/tool/AsyncMcpToolListChangedMethodCallbackTests.java new file mode 100644 index 0000000..7db164b --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/tool/AsyncMcpToolListChangedMethodCallbackTests.java @@ -0,0 +1,279 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.tool; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpToolListChanged; + +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests for {@link AsyncMcpToolListChangedMethodCallback}. + * + * @author Christian Tzolov + */ +public class AsyncMcpToolListChangedMethodCallbackTests { + + private static final List TEST_TOOLS = List.of( + McpSchema.Tool.builder().name("test-tool-1").description("Test Tool 1").inputSchema("{}").build(), + McpSchema.Tool.builder().name("test-tool-2").description("Test Tool 2").inputSchema("{}").build()); + + /** + * Test class with valid methods. + */ + static class ValidMethods { + + private List lastUpdatedTools; + + @McpToolListChanged + public Mono handleToolListChanged(List updatedTools) { + return Mono.fromRunnable(() -> { + this.lastUpdatedTools = updatedTools; + }); + } + + @McpToolListChanged + public void handleToolListChangedVoid(List updatedTools) { + this.lastUpdatedTools = updatedTools; + } + + } + + /** + * Test class with invalid methods. + */ + static class InvalidMethods { + + @McpToolListChanged + public String invalidReturnType(List updatedTools) { + return "Invalid"; + } + + @McpToolListChanged + public Mono invalidMonoReturnType(List updatedTools) { + return Mono.just("Invalid"); + } + + @McpToolListChanged + public Mono invalidParameterCount(List updatedTools, String extra) { + return Mono.empty(); + } + + @McpToolListChanged + public Mono invalidParameterType(String invalidType) { + return Mono.empty(); + } + + @McpToolListChanged + public Mono noParameters() { + return Mono.empty(); + } + + } + + @Test + void testValidMethodWithToolList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleToolListChanged", List.class); + + Function, Mono> callback = AsyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_TOOLS)).verifyComplete(); + + assertThat(bean.lastUpdatedTools).isEqualTo(TEST_TOOLS); + assertThat(bean.lastUpdatedTools).hasSize(2); + assertThat(bean.lastUpdatedTools.get(0).name()).isEqualTo("test-tool-1"); + assertThat(bean.lastUpdatedTools.get(1).name()).isEqualTo("test-tool-2"); + } + + @Test + void testValidVoidMethod() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleToolListChangedVoid", List.class); + + Function, Mono> callback = AsyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_TOOLS)).verifyComplete(); + + assertThat(bean.lastUpdatedTools).isEqualTo(TEST_TOOLS); + assertThat(bean.lastUpdatedTools).hasSize(2); + assertThat(bean.lastUpdatedTools.get(0).name()).isEqualTo("test-tool-1"); + assertThat(bean.lastUpdatedTools.get(1).name()).isEqualTo("test-tool-2"); + } + + @Test + void testInvalidReturnType() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidReturnType", List.class); + + assertThatThrownBy(() -> AsyncMcpToolListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have void or Mono return type"); + } + + @Test + void testInvalidMonoReturnType() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidMonoReturnType", List.class); + + // This will pass validation since we can't check the generic type at runtime + Function, Mono> callback = AsyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + // But it will fail at runtime when we try to cast the result + StepVerifier.create(callback.apply(TEST_TOOLS)).verifyError(ClassCastException.class); + } + + @Test + void testInvalidParameterCount() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidParameterCount", List.class, String.class); + + assertThatThrownBy(() -> AsyncMcpToolListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have exactly 1 parameter (List)"); + } + + @Test + void testInvalidParameterType() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidParameterType", String.class); + + assertThatThrownBy(() -> AsyncMcpToolListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Parameter must be of type List"); + } + + @Test + void testNoParameters() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("noParameters"); + + assertThatThrownBy(() -> AsyncMcpToolListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have exactly 1 parameter (List)"); + } + + @Test + void testNullToolList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleToolListChanged", List.class); + + Function, Mono> callback = AsyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(null)).verifyErrorSatisfies(e -> { + assertThat(e).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Updated tools list must not be null"); + }); + } + + @Test + void testEmptyToolList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleToolListChanged", List.class); + + Function, Mono> callback = AsyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + List emptyList = List.of(); + StepVerifier.create(callback.apply(emptyList)).verifyComplete(); + + assertThat(bean.lastUpdatedTools).isEqualTo(emptyList); + assertThat(bean.lastUpdatedTools).isEmpty(); + } + + @Test + void testNullMethod() { + ValidMethods bean = new ValidMethods(); + + assertThatThrownBy(() -> AsyncMcpToolListChangedMethodCallback.builder().method(null).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must not be null"); + } + + @Test + void testNullBean() throws Exception { + Method method = ValidMethods.class.getMethod("handleToolListChanged", List.class); + + assertThatThrownBy(() -> AsyncMcpToolListChangedMethodCallback.builder().method(method).bean(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Bean must not be null"); + } + + @Test + void testMethodInvocationException() throws Exception { + // Test class that throws an exception in the method + class ThrowingMethod { + + @McpToolListChanged + public Mono handleToolListChanged(List updatedTools) { + return Mono.fromRunnable(() -> { + throw new RuntimeException("Test exception"); + }); + } + + } + + ThrowingMethod bean = new ThrowingMethod(); + Method method = ThrowingMethod.class.getMethod("handleToolListChanged", List.class); + + Function, Mono> callback = AsyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_TOOLS)).verifyError(RuntimeException.class); + } + + @Test + void testMethodInvocationExceptionVoid() throws Exception { + // Test class that throws an exception in a void method + class ThrowingVoidMethod { + + @McpToolListChanged + public void handleToolListChanged(List updatedTools) { + throw new RuntimeException("Test exception"); + } + + } + + ThrowingVoidMethod bean = new ThrowingVoidMethod(); + Method method = ThrowingVoidMethod.class.getMethod("handleToolListChanged", List.class); + + Function, Mono> callback = AsyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_TOOLS)).verifyErrorSatisfies(e -> { + assertThat(e) + .isInstanceOf(AbstractMcpToolListChangedMethodCallback.McpToolListChangedConsumerMethodException.class) + .hasMessageContaining("Error invoking tool list changed consumer method"); + }); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/tool/SyncMcpToolListChangedMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/tool/SyncMcpToolListChangedMethodCallbackTests.java new file mode 100644 index 0000000..9851a9b --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/tool/SyncMcpToolListChangedMethodCallbackTests.java @@ -0,0 +1,203 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.tool; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpToolListChanged; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Tests for {@link SyncMcpToolListChangedMethodCallback}. + * + * @author Christian Tzolov + */ +public class SyncMcpToolListChangedMethodCallbackTests { + + private static final List TEST_TOOLS = List.of( + McpSchema.Tool.builder().name("test-tool-1").description("Test Tool 1").inputSchema("{}").build(), + McpSchema.Tool.builder().name("test-tool-2").description("Test Tool 2").inputSchema("{}").build()); + + /** + * Test class with valid methods. + */ + static class ValidMethods { + + private List lastUpdatedTools; + + @McpToolListChanged + public void handleToolListChanged(List updatedTools) { + this.lastUpdatedTools = updatedTools; + } + + } + + /** + * Test class with invalid methods. + */ + static class InvalidMethods { + + @McpToolListChanged + public String invalidReturnType(List updatedTools) { + return "Invalid"; + } + + @McpToolListChanged + public void invalidParameterCount(List updatedTools, String extra) { + // Invalid parameter count + } + + @McpToolListChanged + public void invalidParameterType(String invalidType) { + // Invalid parameter type + } + + @McpToolListChanged + public void noParameters() { + // No parameters + } + + } + + @Test + void testValidMethodWithToolList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleToolListChanged", List.class); + + Consumer> callback = SyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + callback.accept(TEST_TOOLS); + + assertThat(bean.lastUpdatedTools).isEqualTo(TEST_TOOLS); + assertThat(bean.lastUpdatedTools).hasSize(2); + assertThat(bean.lastUpdatedTools.get(0).name()).isEqualTo("test-tool-1"); + assertThat(bean.lastUpdatedTools.get(1).name()).isEqualTo("test-tool-2"); + } + + @Test + void testInvalidReturnType() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidReturnType", List.class); + + assertThatThrownBy(() -> SyncMcpToolListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have void return type"); + } + + @Test + void testInvalidParameterCount() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidParameterCount", List.class, String.class); + + assertThatThrownBy(() -> SyncMcpToolListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have exactly 1 parameter (List)"); + } + + @Test + void testInvalidParameterType() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidParameterType", String.class); + + assertThatThrownBy(() -> SyncMcpToolListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Parameter must be of type List"); + } + + @Test + void testNoParameters() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("noParameters"); + + assertThatThrownBy(() -> SyncMcpToolListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have exactly 1 parameter (List)"); + } + + @Test + void testNullToolList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleToolListChanged", List.class); + + Consumer> callback = SyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + assertThatThrownBy(() -> callback.accept(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Updated tools list must not be null"); + } + + @Test + void testEmptyToolList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleToolListChanged", List.class); + + Consumer> callback = SyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + List emptyList = List.of(); + callback.accept(emptyList); + + assertThat(bean.lastUpdatedTools).isEqualTo(emptyList); + assertThat(bean.lastUpdatedTools).isEmpty(); + } + + @Test + void testNullMethod() { + ValidMethods bean = new ValidMethods(); + + assertThatThrownBy(() -> SyncMcpToolListChangedMethodCallback.builder().method(null).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must not be null"); + } + + @Test + void testNullBean() throws Exception { + Method method = ValidMethods.class.getMethod("handleToolListChanged", List.class); + + assertThatThrownBy(() -> SyncMcpToolListChangedMethodCallback.builder().method(method).bean(null).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Bean must not be null"); + } + + @Test + void testMethodInvocationException() throws Exception { + // Test class that throws an exception in the method + class ThrowingMethod { + + @McpToolListChanged + public void handleToolListChanged(List updatedTools) { + throw new RuntimeException("Test exception"); + } + + } + + ThrowingMethod bean = new ThrowingMethod(); + Method method = ThrowingMethod.class.getMethod("handleToolListChanged", List.class); + + Consumer> callback = SyncMcpToolListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + assertThatThrownBy(() -> callback.accept(TEST_TOOLS)) + .isInstanceOf(AbstractMcpToolListChangedMethodCallback.McpToolListChangedConsumerMethodException.class) + .hasMessageContaining("Error invoking tool list changed consumer method"); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/tool/AsyncMcpToolListChangedProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/tool/AsyncMcpToolListChangedProviderTests.java new file mode 100644 index 0000000..e359f3e --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/tool/AsyncMcpToolListChangedProviderTests.java @@ -0,0 +1,244 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.provider.changed.tool; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpToolListChanged; +import org.springaicommunity.mcp.method.changed.tool.AsyncToolListChangedSpecification; + +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests for {@link AsyncMcpToolListChangedProvider}. + * + * @author Christian Tzolov + */ +public class AsyncMcpToolListChangedProviderTests { + + private static final List TEST_TOOLS = List.of( + McpSchema.Tool.builder().name("test-tool-1").description("Test Tool 1").inputSchema("{}").build(), + McpSchema.Tool.builder().name("test-tool-2").description("Test Tool 2").inputSchema("{}").build()); + + /** + * Test class with tool list changed consumer methods. + */ + static class ToolListChangedHandler { + + private List lastUpdatedTools; + + @McpToolListChanged + public Mono handleToolListChanged(List updatedTools) { + return Mono.fromRunnable(() -> { + this.lastUpdatedTools = updatedTools; + }); + } + + @McpToolListChanged(clientId = "test-client") + public Mono handleToolListChangedWithClientId(List updatedTools) { + return Mono.fromRunnable(() -> { + this.lastUpdatedTools = updatedTools; + }); + } + + @McpToolListChanged + public void handleToolListChangedVoid(List updatedTools) { + this.lastUpdatedTools = updatedTools; + } + + // This method is not annotated and should be ignored + public Mono notAnnotatedMethod(List updatedTools) { + return Mono.empty(); + } + + } + + @Test + void testGetToolListChangedSpecifications() { + ToolListChangedHandler handler = new ToolListChangedHandler(); + AsyncMcpToolListChangedProvider provider = new AsyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + List, Mono>> consumers = specifications.stream() + .map(AsyncToolListChangedSpecification::toolListChangeHandler) + .toList(); + + // Should find 3 annotated methods (2 Mono + 1 void) + assertThat(consumers).hasSize(3); + assertThat(specifications).hasSize(3); + + // Test the first consumer + StepVerifier.create(consumers.get(0).apply(TEST_TOOLS)).verifyComplete(); + + // Verify that the method was called + assertThat(handler.lastUpdatedTools).isEqualTo(TEST_TOOLS); + assertThat(handler.lastUpdatedTools).hasSize(2); + assertThat(handler.lastUpdatedTools.get(0).name()).isEqualTo("test-tool-1"); + assertThat(handler.lastUpdatedTools.get(1).name()).isEqualTo("test-tool-2"); + + // Test the second consumer + StepVerifier.create(consumers.get(1).apply(TEST_TOOLS)).verifyComplete(); + + // Verify that the method was called + assertThat(handler.lastUpdatedTools).isEqualTo(TEST_TOOLS); + + // Test the third consumer (void method) + StepVerifier.create(consumers.get(2).apply(TEST_TOOLS)).verifyComplete(); + + // Verify that the method was called + assertThat(handler.lastUpdatedTools).isEqualTo(TEST_TOOLS); + } + + @Test + void testClientIdSpecifications() { + ToolListChangedHandler handler = new ToolListChangedHandler(); + AsyncMcpToolListChangedProvider provider = new AsyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + + // Should find 3 specifications + assertThat(specifications).hasSize(3); + + // Check client IDs + List clientIds = specifications.stream().map(AsyncToolListChangedSpecification::clientId).toList(); + + assertThat(clientIds).containsExactlyInAnyOrder("", "test-client", ""); + } + + @Test + void testEmptyList() { + AsyncMcpToolListChangedProvider provider = new AsyncMcpToolListChangedProvider(List.of()); + + List, Mono>> consumers = provider.getToolListChangedSpecifications() + .stream() + .map(AsyncToolListChangedSpecification::toolListChangeHandler) + .toList(); + + assertThat(consumers).isEmpty(); + } + + @Test + void testMultipleObjects() { + ToolListChangedHandler handler1 = new ToolListChangedHandler(); + ToolListChangedHandler handler2 = new ToolListChangedHandler(); + AsyncMcpToolListChangedProvider provider = new AsyncMcpToolListChangedProvider(List.of(handler1, handler2)); + + List, Mono>> consumers = provider.getToolListChangedSpecifications() + .stream() + .map(AsyncToolListChangedSpecification::toolListChangeHandler) + .toList(); + + // Should find 6 annotated methods (3 from each handler) + assertThat(consumers).hasSize(6); + } + + @Test + void testConsumerFunctionality() { + ToolListChangedHandler handler = new ToolListChangedHandler(); + AsyncMcpToolListChangedProvider provider = new AsyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + Function, Mono> consumer = specifications.get(0).toolListChangeHandler(); + + // Test with empty list + List emptyList = List.of(); + StepVerifier.create(consumer.apply(emptyList)).verifyComplete(); + assertThat(handler.lastUpdatedTools).isEqualTo(emptyList); + assertThat(handler.lastUpdatedTools).isEmpty(); + + // Test with test tools + StepVerifier.create(consumer.apply(TEST_TOOLS)).verifyComplete(); + assertThat(handler.lastUpdatedTools).isEqualTo(TEST_TOOLS); + assertThat(handler.lastUpdatedTools).hasSize(2); + } + + @Test + void testNonAnnotatedMethodsIgnored() { + ToolListChangedHandler handler = new ToolListChangedHandler(); + AsyncMcpToolListChangedProvider provider = new AsyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + + // Should only find annotated methods, not the non-annotated one + assertThat(specifications).hasSize(3); + } + + /** + * Test class with methods that should be filtered out (non-reactive return types). + */ + static class InvalidReturnTypeHandler { + + @McpToolListChanged + public String invalidReturnType(List updatedTools) { + return "Invalid"; + } + + @McpToolListChanged + public int anotherInvalidReturnType(List updatedTools) { + return 42; + } + + } + + @Test + void testInvalidReturnTypesFiltered() { + InvalidReturnTypeHandler handler = new InvalidReturnTypeHandler(); + AsyncMcpToolListChangedProvider provider = new AsyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + + // Should find no methods since they have invalid return types + assertThat(specifications).isEmpty(); + } + + /** + * Test class with mixed valid and invalid methods. + */ + static class MixedHandler { + + private List lastUpdatedTools; + + @McpToolListChanged + public Mono validMethod(List updatedTools) { + return Mono.fromRunnable(() -> { + this.lastUpdatedTools = updatedTools; + }); + } + + @McpToolListChanged + public void validVoidMethod(List updatedTools) { + this.lastUpdatedTools = updatedTools; + } + + @McpToolListChanged + public String invalidMethod(List updatedTools) { + return "Invalid"; + } + + } + + @Test + void testMixedValidAndInvalidMethods() { + MixedHandler handler = new MixedHandler(); + AsyncMcpToolListChangedProvider provider = new AsyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + + // Should find only the 2 valid methods (Mono and void) + assertThat(specifications).hasSize(2); + + // Test that the valid methods work + Function, Mono> consumer = specifications.get(0).toolListChangeHandler(); + StepVerifier.create(consumer.apply(TEST_TOOLS)).verifyComplete(); + assertThat(handler.lastUpdatedTools).isEqualTo(TEST_TOOLS); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/tool/SyncMcpToolListChangedProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/tool/SyncMcpToolListChangedProviderTests.java new file mode 100644 index 0000000..67cddb1 --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/tool/SyncMcpToolListChangedProviderTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.provider.changed.tool; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; +import org.springaicommunity.mcp.annotation.McpToolListChanged; +import org.springaicommunity.mcp.method.changed.tool.SyncToolListChangedSpecification; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Tests for {@link SyncMcpToolListChangedProvider}. + * + * @author Christian Tzolov + */ +public class SyncMcpToolListChangedProviderTests { + + private static final List TEST_TOOLS = List.of( + McpSchema.Tool.builder().name("test-tool-1").description("Test Tool 1").inputSchema("{}").build(), + McpSchema.Tool.builder().name("test-tool-2").description("Test Tool 2").inputSchema("{}").build()); + + /** + * Test class with tool list changed consumer methods. + */ + static class ToolListChangedHandler { + + private List lastUpdatedTools; + + @McpToolListChanged + public void handleToolListChanged(List updatedTools) { + this.lastUpdatedTools = updatedTools; + } + + @McpToolListChanged(clientId = "test-client") + public void handleToolListChangedWithClientId(List updatedTools) { + this.lastUpdatedTools = updatedTools; + } + + // This method is not annotated and should be ignored + public void notAnnotatedMethod(List updatedTools) { + // This method should be ignored + } + + } + + @Test + void testGetToolListChangedSpecifications() { + ToolListChangedHandler handler = new ToolListChangedHandler(); + SyncMcpToolListChangedProvider provider = new SyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + List>> consumers = specifications.stream() + .map(SyncToolListChangedSpecification::toolListChangeHandler) + .toList(); + + // Should find 2 annotated methods + assertThat(consumers).hasSize(2); + assertThat(specifications).hasSize(2); + + // Test the first consumer + consumers.get(0).accept(TEST_TOOLS); + + // Verify that the method was called + assertThat(handler.lastUpdatedTools).isEqualTo(TEST_TOOLS); + assertThat(handler.lastUpdatedTools).hasSize(2); + assertThat(handler.lastUpdatedTools.get(0).name()).isEqualTo("test-tool-1"); + assertThat(handler.lastUpdatedTools.get(1).name()).isEqualTo("test-tool-2"); + + // Test the second consumer + consumers.get(1).accept(TEST_TOOLS); + + // Verify that the method was called + assertThat(handler.lastUpdatedTools).isEqualTo(TEST_TOOLS); + } + + @Test + void testClientIdSpecifications() { + ToolListChangedHandler handler = new ToolListChangedHandler(); + SyncMcpToolListChangedProvider provider = new SyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + + // Should find 2 specifications + assertThat(specifications).hasSize(2); + + // Check client IDs + List clientIds = specifications.stream().map(SyncToolListChangedSpecification::clientId).toList(); + + assertThat(clientIds).containsExactlyInAnyOrder("", "test-client"); + } + + @Test + void testEmptyList() { + SyncMcpToolListChangedProvider provider = new SyncMcpToolListChangedProvider(List.of()); + + List>> consumers = provider.getToolListChangedSpecifications() + .stream() + .map(SyncToolListChangedSpecification::toolListChangeHandler) + .toList(); + + assertThat(consumers).isEmpty(); + } + + @Test + void testMultipleObjects() { + ToolListChangedHandler handler1 = new ToolListChangedHandler(); + ToolListChangedHandler handler2 = new ToolListChangedHandler(); + SyncMcpToolListChangedProvider provider = new SyncMcpToolListChangedProvider(List.of(handler1, handler2)); + + List>> consumers = provider.getToolListChangedSpecifications() + .stream() + .map(SyncToolListChangedSpecification::toolListChangeHandler) + .toList(); + + // Should find 4 annotated methods (2 from each handler) + assertThat(consumers).hasSize(4); + } + + @Test + void testConsumerFunctionality() { + ToolListChangedHandler handler = new ToolListChangedHandler(); + SyncMcpToolListChangedProvider provider = new SyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + Consumer> consumer = specifications.get(0).toolListChangeHandler(); + + // Test with empty list + List emptyList = List.of(); + consumer.accept(emptyList); + assertThat(handler.lastUpdatedTools).isEqualTo(emptyList); + assertThat(handler.lastUpdatedTools).isEmpty(); + + // Test with test tools + consumer.accept(TEST_TOOLS); + assertThat(handler.lastUpdatedTools).isEqualTo(TEST_TOOLS); + assertThat(handler.lastUpdatedTools).hasSize(2); + } + + @Test + void testNonAnnotatedMethodsIgnored() { + ToolListChangedHandler handler = new ToolListChangedHandler(); + SyncMcpToolListChangedProvider provider = new SyncMcpToolListChangedProvider(List.of(handler)); + + List specifications = provider.getToolListChangedSpecifications(); + + // Should only find annotated methods, not the non-annotated one + assertThat(specifications).hasSize(2); + } + +}