From 967f3711b0fa19f2595ab2099fbca711c020dc21 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Sun, 24 Aug 2025 17:54:05 +0200 Subject: [PATCH] feat: Add MCP prompt list changed notification support - Add @McpPromptListChanged annotation for handling prompt list change notifications - Implement AbstractMcpPromptListChangedMethodCallback base class with validation - Add SyncMcpPromptListChangedMethodCallback for synchronous prompt list change handling - Add AsyncMcpPromptListChangedMethodCallback for reactive prompt list change handling - Implement SyncMcpPromptListChangedProvider and AsyncMcpPromptListChangedProvider - Add specification records for both sync and async scenarios - Include comprehensive test coverage for all new components - Support clientId filtering for targeted prompt list change notifications - Update README This enables MCP clients to listen for notifications when the list of available prompts changes on connected MCP servers, supporting both synchronous and asynchronous processing patterns. Signed-off-by: Christian Tzolov --- README.md | 133 +++++++++ .../mcp/annotation/McpPromptListChanged.java | 64 ++++ ...actMcpPromptListChangedMethodCallback.java | 211 +++++++++++++ ...yncMcpPromptListChangedMethodCallback.java | 129 ++++++++ .../AsyncPromptListChangedSpecification.java | 15 + ...yncMcpPromptListChangedMethodCallback.java | 105 +++++++ .../SyncPromptListChangedSpecification.java | 14 + .../AsyncMcpPromptListChangedProvider.java | 116 ++++++++ .../SyncMcpPromptListChangedProvider.java | 114 +++++++ ...pPromptListChangedMethodCallbackTests.java | 280 ++++++++++++++++++ ...pPromptListChangedMethodCallbackTests.java | 203 +++++++++++++ ...syncMcpPromptListChangedProviderTests.java | 244 +++++++++++++++ ...SyncMcpPromptListChangedProviderTests.java | 157 ++++++++++ 13 files changed, 1785 insertions(+) create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpPromptListChanged.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/prompt/AbstractMcpPromptListChangedMethodCallback.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/prompt/AsyncMcpPromptListChangedMethodCallback.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/prompt/AsyncPromptListChangedSpecification.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/prompt/SyncMcpPromptListChangedMethodCallback.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/method/changed/prompt/SyncPromptListChangedSpecification.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/prompt/AsyncMcpPromptListChangedProvider.java create mode 100644 mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/prompt/SyncMcpPromptListChangedProvider.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/prompt/AsyncMcpPromptListChangedMethodCallbackTests.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/prompt/SyncMcpPromptListChangedMethodCallbackTests.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/prompt/AsyncMcpPromptListChangedProviderTests.java create mode 100644 mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/prompt/SyncMcpPromptListChangedProviderTests.java diff --git a/README.md b/README.md index f1fa59d..21798e2 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ The Spring integration module provides seamless integration with Spring AI and S - **`@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 +- **`@McpPromptListChanged`** - Annotates methods that handle prompt list change notifications from MCP servers #### Server - **`@McpComplete`** - Annotates methods that provide completion functionality for prompts or URI templates @@ -216,6 +217,8 @@ The project includes provider classes that scan for annotated methods and create - `AsyncMcpToolListChangedProvider` - Processes `@McpToolListChanged` annotations for asynchronous operations - `SyncMcpResourceListChangedProvider` - Processes `@McpResourceListChanged` annotations for synchronous operations - `AsyncMcpResourceListChangedProvider` - Processes `@McpResourceListChanged` annotations for asynchronous operations +- `SyncMcpPromptListChangedProvider` - Processes `@McpPromptListChanged` annotations for synchronous operations +- `AsyncMcpPromptListChangedProvider` - Processes `@McpPromptListChanged` annotations for asynchronous operations #### Stateless Providers (using McpTransportContext) - `SyncStatelessMcpCompleteProvider` - Processes `@McpComplete` annotations for synchronous stateless operations @@ -1441,6 +1444,124 @@ public class MyMcpClient { } ``` +### Mcp Client Prompt List Changed Example + +```java +public class PromptListChangedHandler { + + /** + * Handle prompt list change notifications with a single parameter. + * @param updatedPrompts The updated list of prompts after the change + */ + @McpPromptListChanged + public void handlePromptListChanged(List updatedPrompts) { + System.out.println("Prompt list updated, now contains " + updatedPrompts.size() + " prompts:"); + for (McpSchema.Prompt prompt : updatedPrompts) { + System.out.println(" - " + prompt.name() + ": " + prompt.description()); + } + } + + /** + * Handle prompt list change notifications for a specific client. + * @param updatedPrompts The updated list of prompts after the change + */ + @McpPromptListChanged(clientId = "client-1") + public void handleClient1PromptListChanged(List updatedPrompts) { + System.out.println("Client-1 prompt list updated with " + updatedPrompts.size() + " prompts"); + // Process the updated prompt list for client-1 + updateClientPromptCache("client-1", updatedPrompts); + } + + /** + * Handle prompt list change notifications for another specific client. + * @param updatedPrompts The updated list of prompts after the change + */ + @McpPromptListChanged(clientId = "client-2") + public void handleClient2PromptListChanged(List updatedPrompts) { + System.out.println("Client-2 prompt list updated with " + updatedPrompts.size() + " prompts"); + // Process the updated prompt list for client-2 + updateClientPromptCache("client-2", updatedPrompts); + } + + private void updateClientPromptCache(String clientId, List prompts) { + // Implementation to update prompt cache for specific client + System.out.println("Updated prompt cache for " + clientId + " with " + prompts.size() + " prompts"); + } +} + +public class AsyncPromptListChangedHandler { + + /** + * Handle prompt list change notifications asynchronously. + * @param updatedPrompts The updated list of prompts after the change + * @return A Mono that completes when the notification is handled + */ + @McpPromptListChanged + public Mono handleAsyncPromptListChanged(List updatedPrompts) { + return Mono.fromRunnable(() -> { + System.out.println("Async prompt list update: " + updatedPrompts.size() + " prompts"); + // Process the updated prompt list asynchronously + processPromptListUpdate(updatedPrompts); + }); + } + + /** + * Handle prompt list change notifications for a specific client asynchronously. + * @param updatedPrompts The updated list of prompts after the change + * @return A Mono that completes when the notification is handled + */ + @McpPromptListChanged(clientId = "client-2") + public Mono handleClient2AsyncPromptListChanged(List updatedPrompts) { + return Mono.fromRunnable(() -> { + System.out.println("Client-2 async prompt list update: " + updatedPrompts.size() + " prompts"); + // Process the updated prompt list for client-2 asynchronously + processClientPromptListUpdate("client-2", updatedPrompts); + }).then(); + } + + private void processPromptListUpdate(List prompts) { + // Implementation to process prompt list update + System.out.println("Processing prompt list update with " + prompts.size() + " prompts"); + } + + private void processClientPromptListUpdate(String clientId, List prompts) { + // Implementation to process prompt list update for specific client + System.out.println("Processing prompt list update for " + clientId + " with " + prompts.size() + " prompts"); + } +} + +public class MyMcpClient { + + public static McpSyncClient createSyncClientWithPromptListChanged(PromptListChangedHandler promptListChangedHandler) { + List>> promptListChangedConsumers = + new SyncMcpPromptListChangedProvider(List.of(promptListChangedHandler)).getPromptListChangedConsumers(); + + McpSyncClient client = McpClient.sync(transport) + .capabilities(ClientCapabilities.builder() + // Enable capabilities... + .build()) + .promptListChangedConsumers(promptListChangedConsumers) + .build(); + + return client; + } + + public static McpAsyncClient createAsyncClientWithPromptListChanged(AsyncPromptListChangedHandler asyncPromptListChangedHandler) { + List, Mono>> promptListChangedHandlers = + new AsyncMcpPromptListChangedProvider(List.of(asyncPromptListChangedHandler)).getPromptListChangedHandlers(); + + McpAsyncClient client = McpClient.async(transport) + .capabilities(ClientCapabilities.builder() + // Enable capabilities... + .build()) + .promptListChangedHandlers(promptListChangedHandlers) + .build(); + + return client; + } +} +``` + ### Mcp Client Elicitation Example ```java @@ -1882,6 +2003,18 @@ public class McpConfig { return SpringAiMcpAnnotationProvider.createAsyncResourceListChangedSpecifications(asyncResourceListChangedHandlers); } + @Bean + public List syncPromptListChangedSpecifications( + List promptListChangedHandlers) { + return SpringAiMcpAnnotationProvider.createSyncPromptListChangedSpecifications(promptListChangedHandlers); + } + + @Bean + public List asyncPromptListChangedSpecifications( + List asyncPromptListChangedHandlers) { + return SpringAiMcpAnnotationProvider.createAsyncPromptListChangedSpecifications(asyncPromptListChangedHandlers); + } + // Stateless Spring Integration Examples @Bean diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpPromptListChanged.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpPromptListChanged.java new file mode 100644 index 0000000..e537370 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpPromptListChanged.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 prompt 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 prompts changes on an MCP server. According to the MCP specification, + * servers that declare the {@code listChanged} capability will send notifications when + * their prompt 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 + * prompts after the change notification. + * + *

+ * Example usage:

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

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

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

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

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

+ * Example usage:

{@code
+ * // Create a provider with a list of objects containing @McpPromptListChanged methods
+ * AsyncMcpPromptListChangedProvider provider = new AsyncMcpPromptListChangedProvider(List.of(promptListHandler));
+ *
+ * // Get the list of prompt list changed consumer callbacks
+ * List specifications = provider.getPromptListChangedSpecifications();
+ *
+ * // 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 McpPromptListChanged + * @see AsyncMcpPromptListChangedMethodCallback + * @see AsyncPromptListChangedSpecification + */ +public class AsyncMcpPromptListChangedProvider { + + private final List promptListChangedConsumerObjects; + + /** + * Create a new AsyncMcpPromptListChangedProvider. + * @param promptListChangedConsumerObjects the objects containing methods annotated + * with {@link McpPromptListChanged} + */ + public AsyncMcpPromptListChangedProvider(List promptListChangedConsumerObjects) { + Assert.notNull(promptListChangedConsumerObjects, "promptListChangedConsumerObjects cannot be null"); + this.promptListChangedConsumerObjects = promptListChangedConsumerObjects; + } + + /** + * Get the list of prompt list changed consumer specifications. + * @return the list of prompt list changed consumer specifications + */ + public List getPromptListChangedSpecifications() { + + List promptListChangedConsumers = this.promptListChangedConsumerObjects + .stream() + .map(consumerObject -> Stream.of(doGetClassMethods(consumerObject)) + .filter(method -> method.isAnnotationPresent(McpPromptListChanged.class)) + .filter(method -> method.getReturnType() == void.class + || Mono.class.isAssignableFrom(method.getReturnType())) + .map(mcpPromptListChangedConsumerMethod -> { + var promptListChangedAnnotation = mcpPromptListChangedConsumerMethod + .getAnnotation(McpPromptListChanged.class); + + Function, Mono> methodCallback = AsyncMcpPromptListChangedMethodCallback + .builder() + .method(mcpPromptListChangedConsumerMethod) + .bean(consumerObject) + .build(); + + return new AsyncPromptListChangedSpecification(promptListChangedAnnotation.clientId(), + methodCallback); + }) + .toList()) + .flatMap(List::stream) + .toList(); + + return promptListChangedConsumers; + } + + /** + * 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/prompt/SyncMcpPromptListChangedProvider.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/prompt/SyncMcpPromptListChangedProvider.java new file mode 100644 index 0000000..2728899 --- /dev/null +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/provider/changed/prompt/SyncMcpPromptListChangedProvider.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.prompt; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import org.springaicommunity.mcp.annotation.McpPromptListChanged; +import org.springaicommunity.mcp.method.changed.prompt.SyncPromptListChangedSpecification; +import org.springaicommunity.mcp.method.changed.prompt.SyncMcpPromptListChangedMethodCallback; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.util.Assert; +import reactor.core.publisher.Mono; + +/** + * Provider for synchronous prompt list changed consumer callbacks. + * + *

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

+ * Example usage:

{@code
+ * // Create a provider with a list of objects containing @McpPromptListChanged methods
+ * SyncMcpPromptListChangedProvider provider = new SyncMcpPromptListChangedProvider(List.of(promptListHandler));
+ *
+ * // Get the list of prompt list changed consumer callbacks
+ * List specifications = provider.getPromptListChangedSpecifications();
+ *
+ * // 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 McpPromptListChanged + * @see SyncMcpPromptListChangedMethodCallback + * @see SyncPromptListChangedSpecification + */ +public class SyncMcpPromptListChangedProvider { + + private final List promptListChangedConsumerObjects; + + /** + * Create a new SyncMcpPromptListChangedProvider. + * @param promptListChangedConsumerObjects the objects containing methods annotated + * with {@link McpPromptListChanged} + */ + public SyncMcpPromptListChangedProvider(List promptListChangedConsumerObjects) { + Assert.notNull(promptListChangedConsumerObjects, "promptListChangedConsumerObjects cannot be null"); + this.promptListChangedConsumerObjects = promptListChangedConsumerObjects; + } + + /** + * Get the list of prompt list changed consumer specifications. + * @return the list of prompt list changed consumer specifications + */ + public List getPromptListChangedSpecifications() { + + List promptListChangedConsumers = this.promptListChangedConsumerObjects + .stream() + .map(consumerObject -> Stream.of(doGetClassMethods(consumerObject)) + .filter(method -> method.isAnnotationPresent(McpPromptListChanged.class)) + .filter(method -> !Mono.class.isAssignableFrom(method.getReturnType())) + .map(mcpPromptListChangedConsumerMethod -> { + var promptListChangedAnnotation = mcpPromptListChangedConsumerMethod + .getAnnotation(McpPromptListChanged.class); + + Consumer> methodCallback = SyncMcpPromptListChangedMethodCallback.builder() + .method(mcpPromptListChangedConsumerMethod) + .bean(consumerObject) + .promptListChanged(promptListChangedAnnotation) + .build(); + + return new SyncPromptListChangedSpecification(promptListChangedAnnotation.clientId(), + methodCallback); + }) + .toList()) + .flatMap(List::stream) + .toList(); + + return promptListChangedConsumers; + } + + /** + * 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/prompt/AsyncMcpPromptListChangedMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/prompt/AsyncMcpPromptListChangedMethodCallbackTests.java new file mode 100644 index 0000000..ceb6c2e --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/prompt/AsyncMcpPromptListChangedMethodCallbackTests.java @@ -0,0 +1,280 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.prompt; + +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.McpPromptListChanged; + +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests for {@link AsyncMcpPromptListChangedMethodCallback}. + * + * @author Christian Tzolov + */ +public class AsyncMcpPromptListChangedMethodCallbackTests { + + private static final List TEST_PROMPTS = List.of( + new McpSchema.Prompt("test-prompt-1", "Test Prompt 1", List.of()), + new McpSchema.Prompt("test-prompt-2", "Test Prompt 2", List.of())); + + /** + * Test class with valid methods. + */ + static class ValidMethods { + + private List lastUpdatedPrompts; + + @McpPromptListChanged + public Mono handlePromptListChanged(List updatedPrompts) { + return Mono.fromRunnable(() -> { + this.lastUpdatedPrompts = updatedPrompts; + }); + } + + @McpPromptListChanged + public void handlePromptListChangedVoid(List updatedPrompts) { + this.lastUpdatedPrompts = updatedPrompts; + } + + } + + /** + * Test class with invalid methods. + */ + static class InvalidMethods { + + @McpPromptListChanged + public String invalidReturnType(List updatedPrompts) { + return "Invalid"; + } + + @McpPromptListChanged + public Mono invalidMonoReturnType(List updatedPrompts) { + return Mono.just("Invalid"); + } + + @McpPromptListChanged + public Mono invalidParameterCount(List updatedPrompts, String extra) { + return Mono.empty(); + } + + @McpPromptListChanged + public Mono invalidParameterType(String invalidType) { + return Mono.empty(); + } + + @McpPromptListChanged + public Mono noParameters() { + return Mono.empty(); + } + + } + + @Test + void testValidMethodWithPromptList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handlePromptListChanged", List.class); + + Function, Mono> callback = AsyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_PROMPTS)).verifyComplete(); + + assertThat(bean.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + assertThat(bean.lastUpdatedPrompts).hasSize(2); + assertThat(bean.lastUpdatedPrompts.get(0).name()).isEqualTo("test-prompt-1"); + assertThat(bean.lastUpdatedPrompts.get(1).name()).isEqualTo("test-prompt-2"); + } + + @Test + void testValidVoidMethod() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handlePromptListChangedVoid", List.class); + + Function, Mono> callback = AsyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_PROMPTS)).verifyComplete(); + + assertThat(bean.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + assertThat(bean.lastUpdatedPrompts).hasSize(2); + assertThat(bean.lastUpdatedPrompts.get(0).name()).isEqualTo("test-prompt-1"); + assertThat(bean.lastUpdatedPrompts.get(1).name()).isEqualTo("test-prompt-2"); + } + + @Test + void testInvalidReturnType() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidReturnType", List.class); + + assertThatThrownBy(() -> AsyncMcpPromptListChangedMethodCallback.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 = AsyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + // But it will fail at runtime when we try to cast the result + StepVerifier.create(callback.apply(TEST_PROMPTS)).verifyError(ClassCastException.class); + } + + @Test + void testInvalidParameterCount() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidParameterCount", List.class, String.class); + + assertThatThrownBy(() -> AsyncMcpPromptListChangedMethodCallback.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(() -> AsyncMcpPromptListChangedMethodCallback.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(() -> AsyncMcpPromptListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have exactly 1 parameter (List)"); + } + + @Test + void testNullPromptList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handlePromptListChanged", List.class); + + Function, Mono> callback = AsyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(null)).verifyErrorSatisfies(e -> { + assertThat(e).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Updated prompts list must not be null"); + }); + } + + @Test + void testEmptyPromptList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handlePromptListChanged", List.class); + + Function, Mono> callback = AsyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + List emptyList = List.of(); + StepVerifier.create(callback.apply(emptyList)).verifyComplete(); + + assertThat(bean.lastUpdatedPrompts).isEqualTo(emptyList); + assertThat(bean.lastUpdatedPrompts).isEmpty(); + } + + @Test + void testNullMethod() { + ValidMethods bean = new ValidMethods(); + + assertThatThrownBy(() -> AsyncMcpPromptListChangedMethodCallback.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("handlePromptListChanged", List.class); + + assertThatThrownBy(() -> AsyncMcpPromptListChangedMethodCallback.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 { + + @McpPromptListChanged + public Mono handlePromptListChanged(List updatedPrompts) { + return Mono.fromRunnable(() -> { + throw new RuntimeException("Test exception"); + }); + } + + } + + ThrowingMethod bean = new ThrowingMethod(); + Method method = ThrowingMethod.class.getMethod("handlePromptListChanged", List.class); + + Function, Mono> callback = AsyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_PROMPTS)).verifyError(RuntimeException.class); + } + + @Test + void testMethodInvocationExceptionVoid() throws Exception { + // Test class that throws an exception in a void method + class ThrowingVoidMethod { + + @McpPromptListChanged + public void handlePromptListChanged(List updatedPrompts) { + throw new RuntimeException("Test exception"); + } + + } + + ThrowingVoidMethod bean = new ThrowingVoidMethod(); + Method method = ThrowingVoidMethod.class.getMethod("handlePromptListChanged", List.class); + + Function, Mono> callback = AsyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + StepVerifier.create(callback.apply(TEST_PROMPTS)).verifyErrorSatisfies(e -> { + assertThat(e) + .isInstanceOf( + AbstractMcpPromptListChangedMethodCallback.McpPromptListChangedConsumerMethodException.class) + .hasMessageContaining("Error invoking prompt list changed consumer method"); + }); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/prompt/SyncMcpPromptListChangedMethodCallbackTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/prompt/SyncMcpPromptListChangedMethodCallbackTests.java new file mode 100644 index 0000000..a1d0fc5 --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/changed/prompt/SyncMcpPromptListChangedMethodCallbackTests.java @@ -0,0 +1,203 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.method.changed.prompt; + +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.McpPromptListChanged; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Tests for {@link SyncMcpPromptListChangedMethodCallback}. + * + * @author Christian Tzolov + */ +public class SyncMcpPromptListChangedMethodCallbackTests { + + private static final List TEST_PROMPTS = List.of( + new McpSchema.Prompt("test-prompt-1", "Test Prompt 1", List.of()), + new McpSchema.Prompt("test-prompt-2", "Test Prompt 2", List.of())); + + /** + * Test class with valid methods. + */ + static class ValidMethods { + + private List lastUpdatedPrompts; + + @McpPromptListChanged + public void handlePromptListChanged(List updatedPrompts) { + this.lastUpdatedPrompts = updatedPrompts; + } + + } + + /** + * Test class with invalid methods. + */ + static class InvalidMethods { + + @McpPromptListChanged + public String invalidReturnType(List updatedPrompts) { + return "Invalid"; + } + + @McpPromptListChanged + public void invalidParameterCount(List updatedPrompts, String extra) { + // Invalid parameter count + } + + @McpPromptListChanged + public void invalidParameterType(String invalidType) { + // Invalid parameter type + } + + @McpPromptListChanged + public void noParameters() { + // No parameters + } + + } + + @Test + void testValidMethodWithPromptList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handlePromptListChanged", List.class); + + Consumer> callback = SyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + callback.accept(TEST_PROMPTS); + + assertThat(bean.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + assertThat(bean.lastUpdatedPrompts).hasSize(2); + assertThat(bean.lastUpdatedPrompts.get(0).name()).isEqualTo("test-prompt-1"); + assertThat(bean.lastUpdatedPrompts.get(1).name()).isEqualTo("test-prompt-2"); + } + + @Test + void testInvalidReturnType() throws Exception { + InvalidMethods bean = new InvalidMethods(); + Method method = InvalidMethods.class.getMethod("invalidReturnType", List.class); + + assertThatThrownBy(() -> SyncMcpPromptListChangedMethodCallback.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(() -> SyncMcpPromptListChangedMethodCallback.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(() -> SyncMcpPromptListChangedMethodCallback.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(() -> SyncMcpPromptListChangedMethodCallback.builder().method(method).bean(bean).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Method must have exactly 1 parameter (List)"); + } + + @Test + void testNullPromptList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handlePromptListChanged", List.class); + + Consumer> callback = SyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + assertThatThrownBy(() -> callback.accept(null)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Updated prompts list must not be null"); + } + + @Test + void testEmptyPromptList() throws Exception { + ValidMethods bean = new ValidMethods(); + Method method = ValidMethods.class.getMethod("handlePromptListChanged", List.class); + + Consumer> callback = SyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + List emptyList = List.of(); + callback.accept(emptyList); + + assertThat(bean.lastUpdatedPrompts).isEqualTo(emptyList); + assertThat(bean.lastUpdatedPrompts).isEmpty(); + } + + @Test + void testNullMethod() { + ValidMethods bean = new ValidMethods(); + + assertThatThrownBy(() -> SyncMcpPromptListChangedMethodCallback.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("handlePromptListChanged", List.class); + + assertThatThrownBy(() -> SyncMcpPromptListChangedMethodCallback.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 { + + @McpPromptListChanged + public void handlePromptListChanged(List updatedPrompts) { + throw new RuntimeException("Test exception"); + } + + } + + ThrowingMethod bean = new ThrowingMethod(); + Method method = ThrowingMethod.class.getMethod("handlePromptListChanged", List.class); + + Consumer> callback = SyncMcpPromptListChangedMethodCallback.builder() + .method(method) + .bean(bean) + .build(); + + assertThatThrownBy(() -> callback.accept(TEST_PROMPTS)) + .isInstanceOf(AbstractMcpPromptListChangedMethodCallback.McpPromptListChangedConsumerMethodException.class) + .hasMessageContaining("Error invoking prompt list changed consumer method"); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/prompt/AsyncMcpPromptListChangedProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/prompt/AsyncMcpPromptListChangedProviderTests.java new file mode 100644 index 0000000..1d96316 --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/prompt/AsyncMcpPromptListChangedProviderTests.java @@ -0,0 +1,244 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.provider.changed.prompt; + +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.McpPromptListChanged; +import org.springaicommunity.mcp.method.changed.prompt.AsyncPromptListChangedSpecification; + +import io.modelcontextprotocol.spec.McpSchema; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +/** + * Tests for {@link AsyncMcpPromptListChangedProvider}. + * + * @author Christian Tzolov + */ +public class AsyncMcpPromptListChangedProviderTests { + + private static final List TEST_PROMPTS = List.of( + new McpSchema.Prompt("test-prompt-1", "Test Prompt 1", List.of()), + new McpSchema.Prompt("test-prompt-2", "Test Prompt 2", List.of())); + + /** + * Test class with prompt list changed consumer methods. + */ + static class PromptListChangedHandler { + + private List lastUpdatedPrompts; + + @McpPromptListChanged + public Mono handlePromptListChanged(List updatedPrompts) { + return Mono.fromRunnable(() -> { + this.lastUpdatedPrompts = updatedPrompts; + }); + } + + @McpPromptListChanged(clientId = "test-client") + public Mono handlePromptListChangedWithClientId(List updatedPrompts) { + return Mono.fromRunnable(() -> { + this.lastUpdatedPrompts = updatedPrompts; + }); + } + + @McpPromptListChanged + public void handlePromptListChangedVoid(List updatedPrompts) { + this.lastUpdatedPrompts = updatedPrompts; + } + + // This method is not annotated and should be ignored + public Mono notAnnotatedMethod(List updatedPrompts) { + return Mono.empty(); + } + + } + + @Test + void testGetPromptListChangedSpecifications() { + PromptListChangedHandler handler = new PromptListChangedHandler(); + AsyncMcpPromptListChangedProvider provider = new AsyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + List, Mono>> consumers = specifications.stream() + .map(AsyncPromptListChangedSpecification::promptListChangeHandler) + .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_PROMPTS)).verifyComplete(); + + // Verify that the method was called + assertThat(handler.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + assertThat(handler.lastUpdatedPrompts).hasSize(2); + assertThat(handler.lastUpdatedPrompts.get(0).name()).isEqualTo("test-prompt-1"); + assertThat(handler.lastUpdatedPrompts.get(1).name()).isEqualTo("test-prompt-2"); + + // Test the second consumer + StepVerifier.create(consumers.get(1).apply(TEST_PROMPTS)).verifyComplete(); + + // Verify that the method was called + assertThat(handler.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + + // Test the third consumer (void method) + StepVerifier.create(consumers.get(2).apply(TEST_PROMPTS)).verifyComplete(); + + // Verify that the method was called + assertThat(handler.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + } + + @Test + void testClientIdSpecifications() { + PromptListChangedHandler handler = new PromptListChangedHandler(); + AsyncMcpPromptListChangedProvider provider = new AsyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + + // Should find 3 specifications + assertThat(specifications).hasSize(3); + + // Check client IDs + List clientIds = specifications.stream().map(AsyncPromptListChangedSpecification::clientId).toList(); + + assertThat(clientIds).containsExactlyInAnyOrder("", "test-client", ""); + } + + @Test + void testEmptyList() { + AsyncMcpPromptListChangedProvider provider = new AsyncMcpPromptListChangedProvider(List.of()); + + List, Mono>> consumers = provider.getPromptListChangedSpecifications() + .stream() + .map(AsyncPromptListChangedSpecification::promptListChangeHandler) + .toList(); + + assertThat(consumers).isEmpty(); + } + + @Test + void testMultipleObjects() { + PromptListChangedHandler handler1 = new PromptListChangedHandler(); + PromptListChangedHandler handler2 = new PromptListChangedHandler(); + AsyncMcpPromptListChangedProvider provider = new AsyncMcpPromptListChangedProvider(List.of(handler1, handler2)); + + List, Mono>> consumers = provider.getPromptListChangedSpecifications() + .stream() + .map(AsyncPromptListChangedSpecification::promptListChangeHandler) + .toList(); + + // Should find 6 annotated methods (3 from each handler) + assertThat(consumers).hasSize(6); + } + + @Test + void testConsumerFunctionality() { + PromptListChangedHandler handler = new PromptListChangedHandler(); + AsyncMcpPromptListChangedProvider provider = new AsyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + Function, Mono> consumer = specifications.get(0).promptListChangeHandler(); + + // Test with empty list + List emptyList = List.of(); + StepVerifier.create(consumer.apply(emptyList)).verifyComplete(); + assertThat(handler.lastUpdatedPrompts).isEqualTo(emptyList); + assertThat(handler.lastUpdatedPrompts).isEmpty(); + + // Test with test prompts + StepVerifier.create(consumer.apply(TEST_PROMPTS)).verifyComplete(); + assertThat(handler.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + assertThat(handler.lastUpdatedPrompts).hasSize(2); + } + + @Test + void testNonAnnotatedMethodsIgnored() { + PromptListChangedHandler handler = new PromptListChangedHandler(); + AsyncMcpPromptListChangedProvider provider = new AsyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + + // 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 { + + @McpPromptListChanged + public String invalidReturnType(List updatedPrompts) { + return "Invalid"; + } + + @McpPromptListChanged + public int anotherInvalidReturnType(List updatedPrompts) { + return 42; + } + + } + + @Test + void testInvalidReturnTypesFiltered() { + InvalidReturnTypeHandler handler = new InvalidReturnTypeHandler(); + AsyncMcpPromptListChangedProvider provider = new AsyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + + // 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 lastUpdatedPrompts; + + @McpPromptListChanged + public Mono validMethod(List updatedPrompts) { + return Mono.fromRunnable(() -> { + this.lastUpdatedPrompts = updatedPrompts; + }); + } + + @McpPromptListChanged + public void validVoidMethod(List updatedPrompts) { + this.lastUpdatedPrompts = updatedPrompts; + } + + @McpPromptListChanged + public String invalidMethod(List updatedPrompts) { + return "Invalid"; + } + + } + + @Test + void testMixedValidAndInvalidMethods() { + MixedHandler handler = new MixedHandler(); + AsyncMcpPromptListChangedProvider provider = new AsyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + + // 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).promptListChangeHandler(); + StepVerifier.create(consumer.apply(TEST_PROMPTS)).verifyComplete(); + assertThat(handler.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + } + +} diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/prompt/SyncMcpPromptListChangedProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/prompt/SyncMcpPromptListChangedProviderTests.java new file mode 100644 index 0000000..901567f --- /dev/null +++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/changed/prompt/SyncMcpPromptListChangedProviderTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2025-2025 the original author or authors. + */ + +package org.springaicommunity.mcp.provider.changed.prompt; + +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.McpPromptListChanged; +import org.springaicommunity.mcp.method.changed.prompt.SyncPromptListChangedSpecification; + +import io.modelcontextprotocol.spec.McpSchema; + +/** + * Tests for {@link SyncMcpPromptListChangedProvider}. + * + * @author Christian Tzolov + */ +public class SyncMcpPromptListChangedProviderTests { + + private static final List TEST_PROMPTS = List.of( + new McpSchema.Prompt("test-prompt-1", "Test Prompt 1", List.of()), + new McpSchema.Prompt("test-prompt-2", "Test Prompt 2", List.of())); + + /** + * Test class with prompt list changed consumer methods. + */ + static class PromptListChangedHandler { + + private List lastUpdatedPrompts; + + @McpPromptListChanged + public void handlePromptListChanged(List updatedPrompts) { + this.lastUpdatedPrompts = updatedPrompts; + } + + @McpPromptListChanged(clientId = "test-client") + public void handlePromptListChangedWithClientId(List updatedPrompts) { + this.lastUpdatedPrompts = updatedPrompts; + } + + // This method is not annotated and should be ignored + public void notAnnotatedMethod(List updatedPrompts) { + // This method should be ignored + } + + } + + @Test + void testGetPromptListChangedSpecifications() { + PromptListChangedHandler handler = new PromptListChangedHandler(); + SyncMcpPromptListChangedProvider provider = new SyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + List>> consumers = specifications.stream() + .map(SyncPromptListChangedSpecification::promptListChangeHandler) + .toList(); + + // Should find 2 annotated methods + assertThat(consumers).hasSize(2); + assertThat(specifications).hasSize(2); + + // Test the first consumer + consumers.get(0).accept(TEST_PROMPTS); + + // Verify that the method was called + assertThat(handler.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + assertThat(handler.lastUpdatedPrompts).hasSize(2); + assertThat(handler.lastUpdatedPrompts.get(0).name()).isEqualTo("test-prompt-1"); + assertThat(handler.lastUpdatedPrompts.get(1).name()).isEqualTo("test-prompt-2"); + + // Test the second consumer + consumers.get(1).accept(TEST_PROMPTS); + + // Verify that the method was called + assertThat(handler.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + } + + @Test + void testClientIdSpecifications() { + PromptListChangedHandler handler = new PromptListChangedHandler(); + SyncMcpPromptListChangedProvider provider = new SyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + + // Should find 2 specifications + assertThat(specifications).hasSize(2); + + // Check client IDs + List clientIds = specifications.stream().map(SyncPromptListChangedSpecification::clientId).toList(); + + assertThat(clientIds).containsExactlyInAnyOrder("", "test-client"); + } + + @Test + void testEmptyList() { + SyncMcpPromptListChangedProvider provider = new SyncMcpPromptListChangedProvider(List.of()); + + List>> consumers = provider.getPromptListChangedSpecifications() + .stream() + .map(SyncPromptListChangedSpecification::promptListChangeHandler) + .toList(); + + assertThat(consumers).isEmpty(); + } + + @Test + void testMultipleObjects() { + PromptListChangedHandler handler1 = new PromptListChangedHandler(); + PromptListChangedHandler handler2 = new PromptListChangedHandler(); + SyncMcpPromptListChangedProvider provider = new SyncMcpPromptListChangedProvider(List.of(handler1, handler2)); + + List>> consumers = provider.getPromptListChangedSpecifications() + .stream() + .map(SyncPromptListChangedSpecification::promptListChangeHandler) + .toList(); + + // Should find 4 annotated methods (2 from each handler) + assertThat(consumers).hasSize(4); + } + + @Test + void testConsumerFunctionality() { + PromptListChangedHandler handler = new PromptListChangedHandler(); + SyncMcpPromptListChangedProvider provider = new SyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + Consumer> consumer = specifications.get(0).promptListChangeHandler(); + + // Test with empty list + List emptyList = List.of(); + consumer.accept(emptyList); + assertThat(handler.lastUpdatedPrompts).isEqualTo(emptyList); + assertThat(handler.lastUpdatedPrompts).isEmpty(); + + // Test with test prompts + consumer.accept(TEST_PROMPTS); + assertThat(handler.lastUpdatedPrompts).isEqualTo(TEST_PROMPTS); + assertThat(handler.lastUpdatedPrompts).hasSize(2); + } + + @Test + void testNonAnnotatedMethodsIgnored() { + PromptListChangedHandler handler = new PromptListChangedHandler(); + SyncMcpPromptListChangedProvider provider = new SyncMcpPromptListChangedProvider(List.of(handler)); + + List specifications = provider.getPromptListChangedSpecifications(); + + // Should only find annotated methods, not the non-annotated one + assertThat(specifications).hasSize(2); + } + +}