diff --git a/README.md b/README.md index bb78526..f1fa59d 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ The Spring integration module provides seamless integration with Spring AI and S - **`@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 +- **`@McpResourceListChanged`** - Annotates methods that handle resource list change notifications from MCP servers #### Server - **`@McpComplete`** - Annotates methods that provide completion functionality for prompts or URI templates @@ -188,6 +189,11 @@ The modules provide callback implementations for each operation type: - `SyncMcpToolListChangedMethodCallback` - Synchronous implementation - `AsyncMcpToolListChangedMethodCallback` - Asynchronous implementation using Reactor's Mono +#### Resource List Changed +- `AbstractMcpResourceListChangedMethodCallback` - Base class for resource list changed method callbacks +- `SyncMcpResourceListChangedMethodCallback` - Synchronous implementation +- `AsyncMcpResourceListChangedMethodCallback` - Asynchronous implementation using Reactor's Mono + ### Providers The project includes provider classes that scan for annotated methods and create appropriate callbacks: @@ -208,6 +214,8 @@ The project includes provider classes that scan for annotated methods and create - `AsyncMcpProgressProvider` - Processes `@McpProgress` annotations for asynchronous operations - `SyncMcpToolListChangedProvider` - Processes `@McpToolListChanged` annotations for synchronous operations - `AsyncMcpToolListChangedProvider` - Processes `@McpToolListChanged` annotations for asynchronous operations +- `SyncMcpResourceListChangedProvider` - Processes `@McpResourceListChanged` annotations for synchronous operations +- `AsyncMcpResourceListChangedProvider` - Processes `@McpResourceListChanged` annotations for asynchronous operations #### Stateless Providers (using McpTransportContext) - `SyncStatelessMcpCompleteProvider` - Processes `@McpComplete` annotations for synchronous stateless operations @@ -1315,6 +1323,124 @@ public class MyMcpClient { } ``` +### Mcp Client Resource List Changed Example + +```java +public class ResourceListChangedHandler { + + /** + * Handle resource list change notifications with a single parameter. + * @param updatedResources The updated list of resources after the change + */ + @McpResourceListChanged + public void handleResourceListChanged(List updatedResources) { + System.out.println("Resource list updated, now contains " + updatedResources.size() + " resources:"); + for (McpSchema.Resource resource : updatedResources) { + System.out.println(" - " + resource.name() + ": " + resource.description()); + } + } + + /** + * Handle resource list change notifications for a specific client. + * @param updatedResources The updated list of resources after the change + */ + @McpResourceListChanged(clientId = "client-1") + public void handleClient1ResourceListChanged(List updatedResources) { + System.out.println("Client-1 resource list updated with " + updatedResources.size() + " resources"); + // Process the updated resource list for client-1 + updateClientResourceCache("client-1", updatedResources); + } + + /** + * Handle resource list change notifications for another specific client. + * @param updatedResources The updated list of resources after the change + */ + @McpResourceListChanged(clientId = "client-2") + public void handleClient2ResourceListChanged(List updatedResources) { + System.out.println("Client-2 resource list updated with " + updatedResources.size() + " resources"); + // Process the updated resource list for client-2 + updateClientResourceCache("client-2", updatedResources); + } + + private void updateClientResourceCache(String clientId, List resources) { + // Implementation to update resource cache for specific client + System.out.println("Updated resource cache for " + clientId + " with " + resources.size() + " resources"); + } +} + +public class AsyncResourceListChangedHandler { + + /** + * Handle resource list change notifications asynchronously. + * @param updatedResources The updated list of resources after the change + * @return A Mono that completes when the notification is handled + */ + @McpResourceListChanged + public Mono handleAsyncResourceListChanged(List updatedResources) { + return Mono.fromRunnable(() -> { + System.out.println("Async resource list update: " + updatedResources.size() + " resources"); + // Process the updated resource list asynchronously + processResourceListUpdate(updatedResources); + }); + } + + /** + * Handle resource list change notifications for a specific client asynchronously. + * @param updatedResources The updated list of resources after the change + * @return A Mono that completes when the notification is handled + */ + @McpResourceListChanged(clientId = "client-2") + public Mono handleClient2AsyncResourceListChanged(List updatedResources) { + return Mono.fromRunnable(() -> { + System.out.println("Client-2 async resource list update: " + updatedResources.size() + " resources"); + // Process the updated resource list for client-2 asynchronously + processClientResourceListUpdate("client-2", updatedResources); + }).then(); + } + + private void processResourceListUpdate(List resources) { + // Implementation to process resource list update + System.out.println("Processing resource list update with " + resources.size() + " resources"); + } + + private void processClientResourceListUpdate(String clientId, List resources) { + // Implementation to process resource list update for specific client + System.out.println("Processing resource list update for " + clientId + " with " + resources.size() + " resources"); + } +} + +public class MyMcpClient { + + public static McpSyncClient createSyncClientWithResourceListChanged(ResourceListChangedHandler resourceListChangedHandler) { + List>> resourceListChangedConsumers = + new SyncMcpResourceListChangedProvider(List.of(resourceListChangedHandler)).getResourceListChangedConsumers(); + + McpSyncClient client = McpClient.sync(transport) + .capabilities(ClientCapabilities.builder() + // Enable capabilities... + .build()) + .resourceListChangedConsumers(resourceListChangedConsumers) + .build(); + + return client; + } + + public static McpAsyncClient createAsyncClientWithResourceListChanged(AsyncResourceListChangedHandler asyncResourceListChangedHandler) { + List, Mono>> resourceListChangedHandlers = + new AsyncMcpResourceListChangedProvider(List.of(asyncResourceListChangedHandler)).getResourceListChangedHandlers(); + + McpAsyncClient client = McpClient.async(transport) + .capabilities(ClientCapabilities.builder() + // Enable capabilities... + .build()) + .resourceListChangedHandlers(resourceListChangedHandlers) + .build(); + + return client; + } +} +``` + ### Mcp Client Elicitation Example ```java @@ -1466,7 +1592,6 @@ public class MyMcpClient { } ``` - ### Stateless Examples The library supports stateless implementations that use `McpTransportContext` instead of `McpSyncServerExchange` or `McpAsyncServerExchange`. This is useful for scenarios where you don't need the full server exchange context. @@ -1745,6 +1870,18 @@ public class McpConfig { return SpringAiMcpAnnotationProvider.createAsyncToolListChangedSpecifications(asyncToolListChangedHandlers); } + @Bean + public List syncResourceListChangedSpecifications( + List resourceListChangedHandlers) { + return SpringAiMcpAnnotationProvider.createSyncResourceListChangedSpecifications(resourceListChangedHandlers); + } + + @Bean + public List asyncResourceListChangedSpecifications( + List asyncResourceListChangedHandlers) { + return SpringAiMcpAnnotationProvider.createAsyncResourceListChangedSpecifications(asyncResourceListChangedHandlers); + } + // Stateless Spring Integration Examples @Bean 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 bf279a4..7bf2b68 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,11 +18,13 @@ import java.lang.reflect.Method; import java.util.List; +import org.springaicommunity.mcp.method.changed.resource.AsyncResourceListChangedSpecification; 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.resource.AsyncMcpResourceListChangedProvider; import org.springaicommunity.mcp.provider.changed.tool.AsyncMcpToolListChangedProvider; import org.springaicommunity.mcp.provider.elicitation.AsyncMcpElicitationProvider; import org.springaicommunity.mcp.provider.logging.AsyncMcpLoggingProvider; @@ -158,6 +160,19 @@ protected Method[] doGetClassMethods(Object bean) { } + private static class SpringAiAsyncMcpResourceListChangedProvider extends AsyncMcpResourceListChangedProvider { + + public SpringAiAsyncMcpResourceListChangedProvider(List resourceListChangedObjects) { + super(resourceListChangedObjects); + } + + @Override + protected Method[] doGetClassMethods(Object bean) { + return AnnotationProviderUtil.beanMethods(bean); + } + + } + public static List createAsyncLoggingSpecifications(List loggingObjects) { return new SpringAiAsyncMcpLoggingProvider(loggingObjects).getLoggingSpecifications(); } @@ -199,4 +214,10 @@ public static List createAsyncToolListChanged return new SpringAiAsyncMcpToolListChangedProvider(toolListChangedObjects).getToolListChangedSpecifications(); } + public static List createAsyncResourceListChangedSpecifications( + List resourceListChangedObjects) { + return new SpringAiAsyncMcpResourceListChangedProvider(resourceListChangedObjects) + .getResourceListChangedSpecifications(); + } + } 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 9af824a..3a673be 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,11 +18,13 @@ import java.lang.reflect.Method; import java.util.List; +import org.springaicommunity.mcp.method.changed.resource.SyncResourceListChangedSpecification; 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.resource.SyncMcpResourceListChangedProvider; import org.springaicommunity.mcp.provider.changed.tool.SyncMcpToolListChangedProvider; import org.springaicommunity.mcp.provider.complete.SyncMcpCompletionProvider; import org.springaicommunity.mcp.provider.elicitation.SyncMcpElicitationProvider; @@ -203,6 +205,19 @@ protected Method[] doGetClassMethods(Object bean) { } + private static class SpringAiSyncMcpResourceListChangedProvider extends SyncMcpResourceListChangedProvider { + + public SpringAiSyncMcpResourceListChangedProvider(List resourceListChangedObjects) { + super(resourceListChangedObjects); + } + + @Override + protected Method[] doGetClassMethods(Object bean) { + return AnnotationProviderUtil.beanMethods(bean); + } + + } + public static List createSyncToolSpecifications(List toolObjects) { return new SpringAiSyncToolProvider(toolObjects).getToolSpecifications(); } @@ -256,4 +271,10 @@ public static List createSyncToolListChangedSp return new SpringAiSyncMcpToolListChangedProvider(toolListChangedObjects).getToolListChangedSpecifications(); } + public static List createSyncResourceListChangedSpecifications( + List resourceListChangedObjects) { + return new SpringAiSyncMcpResourceListChangedProvider(resourceListChangedObjects) + .getResourceListChangedSpecifications(); + } + } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpResourceListChanged.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpResourceListChanged.java new file mode 100644 index 0000000..4e55092 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpResourceListChanged.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 resource 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 resources changes on an MCP server. According to the MCP + * specification, servers that declare the {@code listChanged} capability will send + * notifications when their resource 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 + * resources after the change notification. + * + *

+ * Example usage:

{@code
+ * @McpResourceListChanged
+ * public void onResourceListChanged(List updatedResources) {
+ *     // Handle resource list change notification with the updated resources
+ *     logger.info("Resource list updated, now contains {} resources", updatedResources.size());
+ *     // Process the updated resource list
+ * }
+ *
+ * @McpResourceListChanged
+ * public Mono onResourceListChangedAsync(List updatedResources) {
+ *     // Handle resource list change notification asynchronously
+ *     return processUpdatedResources(updatedResources);
+ * }
+ * }
+ * + * @author Christian Tzolov + * @see MCP + * Resource List Changed Notification + */ +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface McpResourceListChanged { + + /** + * Used as connection or client identifier to select the MCP client that the resource + * 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 resource 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/resource/AbstractMcpResourceListChangedMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/resource/AbstractMcpResourceListChangedMethodCallback.java new file mode 100644 index 0000000..9125f61 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/resource/AbstractMcpResourceListChangedMethodCallback.java @@ -0,0 +1,212 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.resource; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.List; + +import org.springaicommunity.mcp.annotation.McpResourceListChanged; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; + +/** + * Abstract base class for creating callbacks around resource list changed consumer + * methods. + * + * This class provides common functionality for both synchronous and asynchronous resource + * list changed consumer method callbacks. It contains shared logic for method validation, + * argument building, and other common operations. + * + * @author Christian Tzolov + */ +public abstract class AbstractMcpResourceListChangedMethodCallback { + + protected final Method method; + + protected final Object bean; + + /** + * Constructor for AbstractMcpResourceListChangedMethodCallback. + * @param method The method to create a callback for + * @param bean The bean instance that contains the method + */ + protected AbstractMcpResourceListChangedMethodCallback(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 resource 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 resource 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 updatedResources The updated list of resources + * @return An array of arguments for the method invocation + */ + protected Object[] buildArgs(Method method, Object exchange, List updatedResources) { + Parameter[] parameters = method.getParameters(); + Object[] args = new Object[parameters.length]; + + // Single parameter (List) + args[0] = updatedResources; + + return args; + } + + /** + * Exception thrown when there is an error invoking a resource list changed consumer + * method. + */ + public static class McpResourceListChangedConsumerMethodException 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 McpResourceListChangedConsumerMethodException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new exception with the specified detail message. + * @param message The detail message + */ + public McpResourceListChangedConsumerMethodException(String message) { + super(message); + } + + } + + /** + * Abstract builder for creating McpResourceListChangedMethodCallback 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 resource list changed annotation. + * @param resourceListChanged The resource list changed annotation + * @return This builder + */ + @SuppressWarnings("unchecked") + public T resourceListChanged(McpResourceListChanged resourceListChanged) { + // 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/resource/AsyncMcpResourceListChangedMethodCallback.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/resource/AsyncMcpResourceListChangedMethodCallback.java new file mode 100644 index 0000000..23f705b --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/resource/AsyncMcpResourceListChangedMethodCallback.java @@ -0,0 +1,129 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.resource; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Function; + +import org.springaicommunity.mcp.annotation.McpResourceListChanged; + +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; + +/** + * Class for creating Function callbacks around resource list changed consumer methods + * that return Mono. + * + * This class provides a way to convert methods annotated with + * {@link McpResourceListChanged} into callback functions that can be used to handle + * resource list change notifications in a reactive way. It supports methods with a single + * List<McpSchema.Resource> parameter. + * + * @author Christian Tzolov + */ +public final class AsyncMcpResourceListChangedMethodCallback extends AbstractMcpResourceListChangedMethodCallback + implements Function, Mono> { + + private AsyncMcpResourceListChangedMethodCallback(Builder builder) { + super(builder.method, builder.bean); + } + + /** + * Apply the callback to the given resource 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 updatedResources The updated list of resources, must not be null + * @return A Mono that completes when the method execution is done + * @throws McpResourceListChangedConsumerMethodException if there is an error invoking + * the resource list changed consumer method + * @throws IllegalArgumentException if the updatedResources is null + */ + @Override + public Mono apply(List updatedResources) { + if (updatedResources == null) { + return Mono.error(new IllegalArgumentException("Updated resources list must not be null")); + } + + try { + // Build arguments for the method call + Object[] args = this.buildArgs(this.method, null, updatedResources); + + // 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 McpResourceListChangedConsumerMethodException( + "Error invoking resource list changed consumer method: " + this.method.getName(), e)); + } + } + + /** + * Validates that the method return type is compatible with the resource 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 AsyncMcpResourceListChangedMethodCallback instances. + *

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

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

+ * This builder provides a fluent API for constructing + * SyncMcpResourceListChangedMethodCallback instances with the required parameters. + */ + public static class Builder extends AbstractBuilder { + + /** + * Build the callback. + * @return A new SyncMcpResourceListChangedMethodCallback instance + */ + @Override + public SyncMcpResourceListChangedMethodCallback build() { + validate(); + return new SyncMcpResourceListChangedMethodCallback(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/resource/SyncResourceListChangedSpecification.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/resource/SyncResourceListChangedSpecification.java new file mode 100644 index 0000000..d81a0c6 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/resource/SyncResourceListChangedSpecification.java @@ -0,0 +1,14 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.resource; + +import java.util.List; +import java.util.function.Consumer; + +import io.modelcontextprotocol.spec.McpSchema; + +public record SyncResourceListChangedSpecification(String clientId, + Consumer> resourceListChangeHandler) { +} diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/resource/AsyncMcpResourceListChangedProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/resource/AsyncMcpResourceListChangedProvider.java new file mode 100644 index 0000000..239ce90 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/resource/AsyncMcpResourceListChangedProvider.java @@ -0,0 +1,116 @@ +/* + * 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.resource; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.springaicommunity.mcp.annotation.McpResourceListChanged; +import org.springaicommunity.mcp.method.changed.resource.AsyncResourceListChangedSpecification; +import org.springaicommunity.mcp.method.changed.resource.AsyncMcpResourceListChangedMethodCallback; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * Provider for asynchronous resource list changed consumer callbacks. + * + *

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

+ * Example usage:

{@code
+ * // Create a provider with a list of objects containing @McpResourceListChanged methods
+ * AsyncMcpResourceListChangedProvider provider = new AsyncMcpResourceListChangedProvider(List.of(resourceListHandler));
+ *
+ * // Get the list of resource list changed consumer callbacks
+ * List specifications = provider.getResourceListChangedSpecifications();
+ *
+ * // 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 McpResourceListChanged + * @see AsyncMcpResourceListChangedMethodCallback + * @see AsyncResourceListChangedSpecification + */ +public class AsyncMcpResourceListChangedProvider { + + private final List resourceListChangedConsumerObjects; + + /** + * Create a new AsyncMcpResourceListChangedProvider. + * @param resourceListChangedConsumerObjects the objects containing methods annotated + * with {@link McpResourceListChanged} + */ + public AsyncMcpResourceListChangedProvider(List resourceListChangedConsumerObjects) { + Assert.notNull(resourceListChangedConsumerObjects, "resourceListChangedConsumerObjects cannot be null"); + this.resourceListChangedConsumerObjects = resourceListChangedConsumerObjects; + } + + /** + * Get the list of resource list changed consumer specifications. + * @return the list of resource list changed consumer specifications + */ + public List getResourceListChangedSpecifications() { + + List resourceListChangedConsumers = this.resourceListChangedConsumerObjects + .stream() + .map(consumerObject -> Stream.of(doGetClassMethods(consumerObject)) + .filter(method -> method.isAnnotationPresent(McpResourceListChanged.class)) + .filter(method -> method.getReturnType() == void.class + || Mono.class.isAssignableFrom(method.getReturnType())) + .map(mcpResourceListChangedConsumerMethod -> { + var resourceListChangedAnnotation = mcpResourceListChangedConsumerMethod + .getAnnotation(McpResourceListChanged.class); + + Function, Mono> methodCallback = AsyncMcpResourceListChangedMethodCallback + .builder() + .method(mcpResourceListChangedConsumerMethod) + .bean(consumerObject) + .build(); + + return new AsyncResourceListChangedSpecification(resourceListChangedAnnotation.clientId(), + methodCallback); + }) + .toList()) + .flatMap(List::stream) + .toList(); + + return resourceListChangedConsumers; + } + + /** + * 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/resource/SyncMcpResourceListChangedProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/resource/SyncMcpResourceListChangedProvider.java new file mode 100644 index 0000000..fcc188f --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/resource/SyncMcpResourceListChangedProvider.java @@ -0,0 +1,115 @@ +/* + * 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.resource; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import org.springaicommunity.mcp.annotation.McpResourceListChanged; +import org.springaicommunity.mcp.method.changed.resource.SyncResourceListChangedSpecification; +import org.springaicommunity.mcp.method.changed.resource.SyncMcpResourceListChangedMethodCallback; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * Provider for synchronous resource list changed consumer callbacks. + * + *

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

+ * Example usage:

{@code
+ * // Create a provider with a list of objects containing @McpResourceListChanged methods
+ * SyncMcpResourceListChangedProvider provider = new SyncMcpResourceListChangedProvider(List.of(resourceListHandler));
+ *
+ * // Get the list of resource list changed consumer callbacks
+ * List specifications = provider.getResourceListChangedSpecifications();
+ *
+ * // 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 McpResourceListChanged + * @see SyncMcpResourceListChangedMethodCallback + * @see SyncResourceListChangedSpecification + */ +public class SyncMcpResourceListChangedProvider { + + private final List resourceListChangedConsumerObjects; + + /** + * Create a new SyncMcpResourceListChangedProvider. + * @param resourceListChangedConsumerObjects the objects containing methods annotated + * with {@link McpResourceListChanged} + */ + public SyncMcpResourceListChangedProvider(List resourceListChangedConsumerObjects) { + Assert.notNull(resourceListChangedConsumerObjects, "resourceListChangedConsumerObjects cannot be null"); + this.resourceListChangedConsumerObjects = resourceListChangedConsumerObjects; + } + + /** + * Get the list of resource list changed consumer specifications. + * @return the list of resource list changed consumer specifications + */ + public List getResourceListChangedSpecifications() { + + List resourceListChangedConsumers = this.resourceListChangedConsumerObjects + .stream() + .map(consumerObject -> Stream.of(doGetClassMethods(consumerObject)) + .filter(method -> method.isAnnotationPresent(McpResourceListChanged.class)) + .filter(method -> !Mono.class.isAssignableFrom(method.getReturnType())) + .map(mcpResourceListChangedConsumerMethod -> { + var resourceListChangedAnnotation = mcpResourceListChangedConsumerMethod + .getAnnotation(McpResourceListChanged.class); + + Consumer> methodCallback = SyncMcpResourceListChangedMethodCallback + .builder() + .method(mcpResourceListChangedConsumerMethod) + .bean(consumerObject) + .resourceListChanged(resourceListChangedAnnotation) + .build(); + + return new SyncResourceListChangedSpecification(resourceListChangedAnnotation.clientId(), + methodCallback); + }) + .toList()) + .flatMap(List::stream) + .toList(); + + return resourceListChangedConsumers; + } + + /** + * 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/resource/AsyncMcpResourceListChangedMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/resource/AsyncMcpResourceListChangedMethodCallbackTests.java new file mode 100644 index 0000000..03eb67a --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/resource/AsyncMcpResourceListChangedMethodCallbackTests.java @@ -0,0 +1,289 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.resource; + +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.McpResourceListChanged; + +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests for {@link AsyncMcpResourceListChangedMethodCallback}. + * + * @author Christian Tzolov + */ +public class AsyncMcpResourceListChangedMethodCallbackTests { + + private static final List TEST_RESOURCES = List.of( + McpSchema.Resource.builder() + .uri("file:///test1.txt") + .name("test-resource-1") + .description("Test Resource 1") + .mimeType("text/plain") + .build(), + McpSchema.Resource.builder() + .uri("file:///test2.txt") + .name("test-resource-2") + .description("Test Resource 2") + .mimeType("text/plain") + .build()); + + /** + * Test class with valid methods. + */ + static class ValidMethods { + + private List lastUpdatedResources; + + @McpResourceListChanged + public Mono handleResourceListChanged(List updatedResources) { + return Mono.fromRunnable(() -> { + this.lastUpdatedResources = updatedResources; + }); + } + + @McpResourceListChanged + public void handleResourceListChangedVoid(List updatedResources) { + this.lastUpdatedResources = updatedResources; + } + + } + + /** + * Test class with invalid methods. + */ + static class InvalidMethods { + + @McpResourceListChanged + public String invalidReturnType(List updatedResources) { + return "Invalid"; + } + + @McpResourceListChanged + public Mono invalidMonoReturnType(List updatedResources) { + return Mono.just("Invalid"); + } + + @McpResourceListChanged + public Mono invalidParameterCount(List updatedResources, String extra) { + return Mono.empty(); + } + + @McpResourceListChanged + public Mono invalidParameterType(String invalidType) { + return Mono.empty(); + } + + @McpResourceListChanged + public Mono noParameters() { + return Mono.empty(); + } + + } + + @Test + void testValidMethodWithResourceList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleResourceListChanged", List.class); + + Function, Mono> callback = AsyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_RESOURCES)).verifyComplete(); + + assertThat(bean.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + assertThat(bean.lastUpdatedResources).hasSize(2); + assertThat(bean.lastUpdatedResources.get(0).name()).isEqualTo("test-resource-1"); + assertThat(bean.lastUpdatedResources.get(1).name()).isEqualTo("test-resource-2"); + } + + @Test + void testValidVoidMethod() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleResourceListChangedVoid", List.class); + + Function, Mono> callback = AsyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_RESOURCES)).verifyComplete(); + + assertThat(bean.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + assertThat(bean.lastUpdatedResources).hasSize(2); + assertThat(bean.lastUpdatedResources.get(0).name()).isEqualTo("test-resource-1"); + assertThat(bean.lastUpdatedResources.get(1).name()).isEqualTo("test-resource-2"); + } + + @Test + void testInvalidReturnType() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidReturnType", List.class); + + assertThatThrownBy(() -> AsyncMcpResourceListChangedMethodCallback.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 = AsyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + // But it will fail at runtime when we try to cast the result + StepVerifier.create(callback.apply(TEST_RESOURCES)).verifyError(ClassCastException.class); + } + + @Test + void testInvalidParameterCount() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidParameterCount", List.class, String.class); + + assertThatThrownBy(() -> AsyncMcpResourceListChangedMethodCallback.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(() -> AsyncMcpResourceListChangedMethodCallback.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(() -> AsyncMcpResourceListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have exactly 1 parameter (List)"); + } + + @Test + void testNullResourceList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleResourceListChanged", List.class); + + Function, Mono> callback = AsyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(null)).verifyErrorSatisfies(e -> { + assertThat(e).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Updated resources list must not be null"); + }); + } + + @Test + void testEmptyResourceList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleResourceListChanged", List.class); + + Function, Mono> callback = AsyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + List emptyList = List.of(); + StepVerifier.create(callback.apply(emptyList)).verifyComplete(); + + assertThat(bean.lastUpdatedResources).isEqualTo(emptyList); + assertThat(bean.lastUpdatedResources).isEmpty(); + } + + @Test + void testNullMethod() { + ValidMethods bean = new ValidMethods(); + + assertThatThrownBy(() -> AsyncMcpResourceListChangedMethodCallback.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("handleResourceListChanged", List.class); + + assertThatThrownBy(() -> AsyncMcpResourceListChangedMethodCallback.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 { + + @McpResourceListChanged + public Mono handleResourceListChanged(List updatedResources) { + return Mono.fromRunnable(() -> { + throw new RuntimeException("Test exception"); + }); + } + + } + + ThrowingMethod bean = new ThrowingMethod(); + Method method = ThrowingMethod.class.getMethod("handleResourceListChanged", List.class); + + Function, Mono> callback = AsyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_RESOURCES)).verifyError(RuntimeException.class); + } + + @Test + void testMethodInvocationExceptionVoid() throws Exception { + // Test class that throws an exception in a void method + class ThrowingVoidMethod { + + @McpResourceListChanged + public void handleResourceListChanged(List updatedResources) { + throw new RuntimeException("Test exception"); + } + + } + + ThrowingVoidMethod bean = new ThrowingVoidMethod(); + Method method = ThrowingVoidMethod.class.getMethod("handleResourceListChanged", List.class); + + Function, Mono> callback = AsyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_RESOURCES)).verifyErrorSatisfies(e -> { + assertThat(e).isInstanceOf( + AbstractMcpResourceListChangedMethodCallback.McpResourceListChangedConsumerMethodException.class) + .hasMessageContaining("Error invoking resource list changed consumer method"); + }); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/resource/SyncMcpResourceListChangedMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/resource/SyncMcpResourceListChangedMethodCallbackTests.java new file mode 100644 index 0000000..64d42ac --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/resource/SyncMcpResourceListChangedMethodCallbackTests.java @@ -0,0 +1,214 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.resource; + +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.McpResourceListChanged; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Tests for {@link SyncMcpResourceListChangedMethodCallback}. + * + * @author Christian Tzolov + */ +public class SyncMcpResourceListChangedMethodCallbackTests { + + private static final List TEST_RESOURCES = List.of( + McpSchema.Resource.builder() + .uri("file:///test1.txt") + .name("test-resource-1") + .description("Test Resource 1") + .mimeType("text/plain") + .build(), + McpSchema.Resource.builder() + .uri("file:///test2.txt") + .name("test-resource-2") + .description("Test Resource 2") + .mimeType("text/plain") + .build()); + + /** + * Test class with valid methods. + */ + static class ValidMethods { + + private List lastUpdatedResources; + + @McpResourceListChanged + public void handleResourceListChanged(List updatedResources) { + this.lastUpdatedResources = updatedResources; + } + + } + + /** + * Test class with invalid methods. + */ + static class InvalidMethods { + + @McpResourceListChanged + public String invalidReturnType(List updatedResources) { + return "Invalid"; + } + + @McpResourceListChanged + public void invalidParameterCount(List updatedResources, String extra) { + // Invalid parameter count + } + + @McpResourceListChanged + public void invalidParameterType(String invalidType) { + // Invalid parameter type + } + + @McpResourceListChanged + public void noParameters() { + // No parameters + } + + } + + @Test + void testValidMethodWithResourceList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleResourceListChanged", List.class); + + Consumer> callback = SyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + callback.accept(TEST_RESOURCES); + + assertThat(bean.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + assertThat(bean.lastUpdatedResources).hasSize(2); + assertThat(bean.lastUpdatedResources.get(0).name()).isEqualTo("test-resource-1"); + assertThat(bean.lastUpdatedResources.get(1).name()).isEqualTo("test-resource-2"); + } + + @Test + void testInvalidReturnType() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidReturnType", List.class); + + assertThatThrownBy(() -> SyncMcpResourceListChangedMethodCallback.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(() -> SyncMcpResourceListChangedMethodCallback.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(() -> SyncMcpResourceListChangedMethodCallback.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(() -> SyncMcpResourceListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have exactly 1 parameter (List)"); + } + + @Test + void testNullResourceList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleResourceListChanged", List.class); + + Consumer> callback = SyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + assertThatThrownBy(() -> callback.accept(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Updated resources list must not be null"); + } + + @Test + void testEmptyResourceList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handleResourceListChanged", List.class); + + Consumer> callback = SyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + List emptyList = List.of(); + callback.accept(emptyList); + + assertThat(bean.lastUpdatedResources).isEqualTo(emptyList); + assertThat(bean.lastUpdatedResources).isEmpty(); + } + + @Test + void testNullMethod() { + ValidMethods bean = new ValidMethods(); + + assertThatThrownBy(() -> SyncMcpResourceListChangedMethodCallback.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("handleResourceListChanged", List.class); + + assertThatThrownBy(() -> SyncMcpResourceListChangedMethodCallback.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 { + + @McpResourceListChanged + public void handleResourceListChanged(List updatedResources) { + throw new RuntimeException("Test exception"); + } + + } + + ThrowingMethod bean = new ThrowingMethod(); + Method method = ThrowingMethod.class.getMethod("handleResourceListChanged", List.class); + + Consumer> callback = SyncMcpResourceListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + assertThatThrownBy(() -> callback.accept(TEST_RESOURCES)) + .isInstanceOf( + AbstractMcpResourceListChangedMethodCallback.McpResourceListChangedConsumerMethodException.class) + .hasMessageContaining("Error invoking resource list changed consumer method"); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/resource/AsyncMcpResourceListChangedProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/resource/AsyncMcpResourceListChangedProviderTests.java new file mode 100644 index 0000000..cfec18b --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/resource/AsyncMcpResourceListChangedProviderTests.java @@ -0,0 +1,255 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.provider.changed.resource; + +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.McpResourceListChanged; +import org.springaicommunity.mcp.method.changed.resource.AsyncResourceListChangedSpecification; + +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests for {@link AsyncMcpResourceListChangedProvider}. + * + * @author Christian Tzolov + */ +public class AsyncMcpResourceListChangedProviderTests { + + private static final List TEST_RESOURCES = List.of( + McpSchema.Resource.builder() + .uri("file:///test1.txt") + .name("test-resource-1") + .description("Test Resource 1") + .mimeType("text/plain") + .build(), + McpSchema.Resource.builder() + .uri("file:///test2.txt") + .name("test-resource-2") + .description("Test Resource 2") + .mimeType("text/plain") + .build()); + + /** + * Test class with resource list changed consumer methods. + */ + static class ResourceListChangedHandler { + + private List lastUpdatedResources; + + @McpResourceListChanged + public Mono handleResourceListChanged(List updatedResources) { + return Mono.fromRunnable(() -> { + this.lastUpdatedResources = updatedResources; + }); + } + + @McpResourceListChanged(clientId = "test-client") + public Mono handleResourceListChangedWithClientId(List updatedResources) { + return Mono.fromRunnable(() -> { + this.lastUpdatedResources = updatedResources; + }); + } + + @McpResourceListChanged + public void handleResourceListChangedVoid(List updatedResources) { + this.lastUpdatedResources = updatedResources; + } + + // This method is not annotated and should be ignored + public Mono notAnnotatedMethod(List updatedResources) { + return Mono.empty(); + } + + } + + @Test + void testGetResourceListChangedSpecifications() { + ResourceListChangedHandler handler = new ResourceListChangedHandler(); + AsyncMcpResourceListChangedProvider provider = new AsyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + List, Mono>> consumers = specifications.stream() + .map(AsyncResourceListChangedSpecification::resourceListChangeHandler) + .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_RESOURCES)).verifyComplete(); + + // Verify that the method was called + assertThat(handler.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + assertThat(handler.lastUpdatedResources).hasSize(2); + assertThat(handler.lastUpdatedResources.get(0).name()).isEqualTo("test-resource-1"); + assertThat(handler.lastUpdatedResources.get(1).name()).isEqualTo("test-resource-2"); + + // Test the second consumer + StepVerifier.create(consumers.get(1).apply(TEST_RESOURCES)).verifyComplete(); + + // Verify that the method was called + assertThat(handler.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + + // Test the third consumer (void method) + StepVerifier.create(consumers.get(2).apply(TEST_RESOURCES)).verifyComplete(); + + // Verify that the method was called + assertThat(handler.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + } + + @Test + void testClientIdSpecifications() { + ResourceListChangedHandler handler = new ResourceListChangedHandler(); + AsyncMcpResourceListChangedProvider provider = new AsyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + + // Should find 3 specifications + assertThat(specifications).hasSize(3); + + // Check client IDs + List clientIds = specifications.stream().map(AsyncResourceListChangedSpecification::clientId).toList(); + + assertThat(clientIds).containsExactlyInAnyOrder("", "test-client", ""); + } + + @Test + void testEmptyList() { + AsyncMcpResourceListChangedProvider provider = new AsyncMcpResourceListChangedProvider(List.of()); + + List, Mono>> consumers = provider.getResourceListChangedSpecifications() + .stream() + .map(AsyncResourceListChangedSpecification::resourceListChangeHandler) + .toList(); + + assertThat(consumers).isEmpty(); + } + + @Test + void testMultipleObjects() { + ResourceListChangedHandler handler1 = new ResourceListChangedHandler(); + ResourceListChangedHandler handler2 = new ResourceListChangedHandler(); + AsyncMcpResourceListChangedProvider provider = new AsyncMcpResourceListChangedProvider( + List.of(handler1, handler2)); + + List, Mono>> consumers = provider.getResourceListChangedSpecifications() + .stream() + .map(AsyncResourceListChangedSpecification::resourceListChangeHandler) + .toList(); + + // Should find 6 annotated methods (3 from each handler) + assertThat(consumers).hasSize(6); + } + + @Test + void testConsumerFunctionality() { + ResourceListChangedHandler handler = new ResourceListChangedHandler(); + AsyncMcpResourceListChangedProvider provider = new AsyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + Function, Mono> consumer = specifications.get(0).resourceListChangeHandler(); + + // Test with empty list + List emptyList = List.of(); + StepVerifier.create(consumer.apply(emptyList)).verifyComplete(); + assertThat(handler.lastUpdatedResources).isEqualTo(emptyList); + assertThat(handler.lastUpdatedResources).isEmpty(); + + // Test with test resources + StepVerifier.create(consumer.apply(TEST_RESOURCES)).verifyComplete(); + assertThat(handler.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + assertThat(handler.lastUpdatedResources).hasSize(2); + } + + @Test + void testNonAnnotatedMethodsIgnored() { + ResourceListChangedHandler handler = new ResourceListChangedHandler(); + AsyncMcpResourceListChangedProvider provider = new AsyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + + // 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 { + + @McpResourceListChanged + public String invalidReturnType(List updatedResources) { + return "Invalid"; + } + + @McpResourceListChanged + public int anotherInvalidReturnType(List updatedResources) { + return 42; + } + + } + + @Test + void testInvalidReturnTypesFiltered() { + InvalidReturnTypeHandler handler = new InvalidReturnTypeHandler(); + AsyncMcpResourceListChangedProvider provider = new AsyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + + // 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 lastUpdatedResources; + + @McpResourceListChanged + public Mono validMethod(List updatedResources) { + return Mono.fromRunnable(() -> { + this.lastUpdatedResources = updatedResources; + }); + } + + @McpResourceListChanged + public void validVoidMethod(List updatedResources) { + this.lastUpdatedResources = updatedResources; + } + + @McpResourceListChanged + public String invalidMethod(List updatedResources) { + return "Invalid"; + } + + } + + @Test + void testMixedValidAndInvalidMethods() { + MixedHandler handler = new MixedHandler(); + AsyncMcpResourceListChangedProvider provider = new AsyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + + // 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).resourceListChangeHandler(); + StepVerifier.create(consumer.apply(TEST_RESOURCES)).verifyComplete(); + assertThat(handler.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/resource/SyncMcpResourceListChangedProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/resource/SyncMcpResourceListChangedProviderTests.java new file mode 100644 index 0000000..5e744c8 --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/resource/SyncMcpResourceListChangedProviderTests.java @@ -0,0 +1,168 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.provider.changed.resource; + +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.McpResourceListChanged; +import org.springaicommunity.mcp.method.changed.resource.SyncResourceListChangedSpecification; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Tests for {@link SyncMcpResourceListChangedProvider}. + * + * @author Christian Tzolov + */ +public class SyncMcpResourceListChangedProviderTests { + + private static final List TEST_RESOURCES = List.of( + McpSchema.Resource.builder() + .uri("file:///test1.txt") + .name("test-resource-1") + .description("Test Resource 1") + .mimeType("text/plain") + .build(), + McpSchema.Resource.builder() + .uri("file:///test2.txt") + .name("test-resource-2") + .description("Test Resource 2") + .mimeType("text/plain") + .build()); + + /** + * Test class with resource list changed consumer methods. + */ + static class ResourceListChangedHandler { + + private List lastUpdatedResources; + + @McpResourceListChanged + public void handleResourceListChanged(List updatedResources) { + this.lastUpdatedResources = updatedResources; + } + + @McpResourceListChanged(clientId = "test-client") + public void handleResourceListChangedWithClientId(List updatedResources) { + this.lastUpdatedResources = updatedResources; + } + + // This method is not annotated and should be ignored + public void notAnnotatedMethod(List updatedResources) { + // This method should be ignored + } + + } + + @Test + void testGetResourceListChangedSpecifications() { + ResourceListChangedHandler handler = new ResourceListChangedHandler(); + SyncMcpResourceListChangedProvider provider = new SyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + List>> consumers = specifications.stream() + .map(SyncResourceListChangedSpecification::resourceListChangeHandler) + .toList(); + + // Should find 2 annotated methods + assertThat(consumers).hasSize(2); + assertThat(specifications).hasSize(2); + + // Test the first consumer + consumers.get(0).accept(TEST_RESOURCES); + + // Verify that the method was called + assertThat(handler.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + assertThat(handler.lastUpdatedResources).hasSize(2); + assertThat(handler.lastUpdatedResources.get(0).name()).isEqualTo("test-resource-1"); + assertThat(handler.lastUpdatedResources.get(1).name()).isEqualTo("test-resource-2"); + + // Test the second consumer + consumers.get(1).accept(TEST_RESOURCES); + + // Verify that the method was called + assertThat(handler.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + } + + @Test + void testClientIdSpecifications() { + ResourceListChangedHandler handler = new ResourceListChangedHandler(); + SyncMcpResourceListChangedProvider provider = new SyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + + // Should find 2 specifications + assertThat(specifications).hasSize(2); + + // Check client IDs + List clientIds = specifications.stream().map(SyncResourceListChangedSpecification::clientId).toList(); + + assertThat(clientIds).containsExactlyInAnyOrder("", "test-client"); + } + + @Test + void testEmptyList() { + SyncMcpResourceListChangedProvider provider = new SyncMcpResourceListChangedProvider(List.of()); + + List>> consumers = provider.getResourceListChangedSpecifications() + .stream() + .map(SyncResourceListChangedSpecification::resourceListChangeHandler) + .toList(); + + assertThat(consumers).isEmpty(); + } + + @Test + void testMultipleObjects() { + ResourceListChangedHandler handler1 = new ResourceListChangedHandler(); + ResourceListChangedHandler handler2 = new ResourceListChangedHandler(); + SyncMcpResourceListChangedProvider provider = new SyncMcpResourceListChangedProvider( + List.of(handler1, handler2)); + + List>> consumers = provider.getResourceListChangedSpecifications() + .stream() + .map(SyncResourceListChangedSpecification::resourceListChangeHandler) + .toList(); + + // Should find 4 annotated methods (2 from each handler) + assertThat(consumers).hasSize(4); + } + + @Test + void testConsumerFunctionality() { + ResourceListChangedHandler handler = new ResourceListChangedHandler(); + SyncMcpResourceListChangedProvider provider = new SyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + Consumer> consumer = specifications.get(0).resourceListChangeHandler(); + + // Test with empty list + List emptyList = List.of(); + consumer.accept(emptyList); + assertThat(handler.lastUpdatedResources).isEqualTo(emptyList); + assertThat(handler.lastUpdatedResources).isEmpty(); + + // Test with test resources + consumer.accept(TEST_RESOURCES); + assertThat(handler.lastUpdatedResources).isEqualTo(TEST_RESOURCES); + assertThat(handler.lastUpdatedResources).hasSize(2); + } + + @Test + void testNonAnnotatedMethodsIgnored() { + ResourceListChangedHandler handler = new ResourceListChangedHandler(); + SyncMcpResourceListChangedProvider provider = new SyncMcpResourceListChangedProvider(List.of(handler)); + + List specifications = provider.getResourceListChangedSpecifications(); + + // Should only find annotated methods, not the non-annotated one + assertThat(specifications).hasSize(2); + } + +}