diff --git a/README.md b/README.md index b8191b0..db6aeb9 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ The Spring integration module provides seamless integration with Spring AI and S #### Client - **`@McpLogging`** - Annotates methods that handle logging message notifications from MCP servers (requires `clientId` parameter) - **`@McpSampling`** - Annotates methods that handle sampling requests from MCP servers -- **`@McpElicitation`** - Annotates methods that handle elicitation requests to gather additional information from users +- **`@McpElicitation`** - Annotates methods that handle elicitation requests to gather additional information from users (requires `clientId` parameter) - **`@McpProgress`** - Annotates methods that handle progress notifications for long-running operations (requires `clientId` parameter) - **`@McpToolListChanged`** - Annotates methods that handle tool list change notifications from MCP servers - **`@McpResourceListChanged`** - Annotates methods that handle resource list change notifications from MCP servers @@ -1579,10 +1579,11 @@ public class ElicitationHandler { /** * Handle elicitation requests with a synchronous implementation. + * Note: clientId is required for all @McpElicitation annotations. * @param request The elicitation request * @return The elicitation result */ - @McpElicitation + @McpElicitation(clientId = "default-client") public ElicitResult handleElicitationRequest(ElicitRequest request) { // Example implementation that accepts the request and returns user data // In a real implementation, this would present a form to the user @@ -1616,10 +1617,11 @@ public class ElicitationHandler { /** * Handle elicitation requests that should be declined. + * Note: clientId is now required for all @McpElicitation annotations. * @param request The elicitation request * @return The elicitation result with decline action */ - @McpElicitation + @McpElicitation(clientId = "default-client") public ElicitResult handleDeclineElicitationRequest(ElicitRequest request) { // Example of declining an elicitation request return new ElicitResult(ElicitResult.Action.DECLINE, null); @@ -1643,10 +1645,11 @@ public class AsyncElicitationHandler { /** * Handle elicitation requests with an asynchronous implementation. + * Note: clientId is required for all @McpElicitation annotations. * @param request The elicitation request * @return A Mono containing the elicitation result */ - @McpElicitation + @McpElicitation(clientId = "default-client") public Mono handleAsyncElicitationRequest(ElicitRequest request) { return Mono.fromCallable(() -> { // Simulate async processing of the elicitation request @@ -1664,10 +1667,11 @@ public class AsyncElicitationHandler { /** * Handle elicitation requests that might be cancelled. + * Note: clientId is required for all @McpElicitation annotations. * @param request The elicitation request * @return A Mono containing the elicitation result with cancel action */ - @McpElicitation + @McpElicitation(clientId = "default-client") public Mono handleCancelElicitationRequest(ElicitRequest request) { return Mono.just(new ElicitResult(ElicitResult.Action.CANCEL, null)); } diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpElicitation.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpElicitation.java index 343bca9..e7b0afb 100644 --- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpElicitation.java +++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpElicitation.java @@ -24,7 +24,7 @@ * *

* Example usage:

{@code
- * @McpElicitation
+ * @McpElicitation(clientId = "my-client-id")
  * public ElicitResult handleElicitationRequest(ElicitRequest request) {
  *     return ElicitResult.builder()
  *         .message("Generated response")
@@ -33,7 +33,7 @@
  *         .build();
  * }
  *
- * @McpElicitation
+ * @McpElicitation(clientId = "my-client-id")
  * public Mono handleAsyncElicitationRequest(ElicitRequest request) {
  *     return Mono.just(ElicitResult.builder()
  *         .message("Generated response")
@@ -54,8 +54,8 @@
 
 	/**
 	 * Used as connection or client identifier to select the MCP client, the elicitation
-	 * method is associated with. If not specified, is applied to all clients.
+	 * method is associated with.
 	 */
-	String clientId() default "";
+	String clientId();
 
 }
diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AsyncElicitationSpecification.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AsyncElicitationSpecification.java
index 603b2fd..da1105e 100644
--- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AsyncElicitationSpecification.java
+++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AsyncElicitationSpecification.java
@@ -4,6 +4,7 @@
 
 package org.springaicommunity.mcp.method.elicitation;
 
+import java.util.Objects;
 import java.util.function.Function;
 
 import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
@@ -13,4 +14,12 @@
 public record AsyncElicitationSpecification(String clientId,
 		Function> elicitationHandler) {
 
+	public AsyncElicitationSpecification {
+		Objects.requireNonNull(clientId, "clientId must not be null");
+		if (clientId.trim().isEmpty()) {
+			throw new IllegalArgumentException("clientId must not be empty");
+		}
+		Objects.requireNonNull(elicitationHandler, "elicitationHandler must not be null");
+	}
+
 }
diff --git a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/SyncElicitationSpecification.java b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/SyncElicitationSpecification.java
index 6f61145..53eda1c 100644
--- a/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/SyncElicitationSpecification.java
+++ b/mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/SyncElicitationSpecification.java
@@ -4,11 +4,19 @@
 
 package org.springaicommunity.mcp.method.elicitation;
 
+import java.util.Objects;
 import java.util.function.Function;
 
 import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
 import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
 
 public record SyncElicitationSpecification(String clientId, Function elicitationHandler) {
+	public SyncElicitationSpecification {
+		Objects.requireNonNull(clientId, "clientId must not be null");
+		if (clientId.trim().isEmpty()) {
+			throw new IllegalArgumentException("clientId must not be empty");
+		}
+		Objects.requireNonNull(elicitationHandler, "elicitationHandler must not be null");
+	}
 
 }
diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallbackExample.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallbackExample.java
index 9c7f04d..03b2dc7 100644
--- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallbackExample.java
+++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallbackExample.java
@@ -19,28 +19,28 @@
  */
 public class AsyncMcpElicitationMethodCallbackExample {
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public Mono handleElicitationRequest(ElicitRequest request) {
 		// Example implementation that accepts the request and returns some content
 		return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("userInput", "Example async user input",
 				"confirmed", true, "timestamp", System.currentTimeMillis())));
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public Mono handleDeclineElicitationRequest(ElicitRequest request) {
 		// Example implementation that declines the request after a delay
 		return Mono.delay(java.time.Duration.ofMillis(100))
 			.then(Mono.just(new ElicitResult(ElicitResult.Action.DECLINE, null)));
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public ElicitResult handleSyncElicitationRequest(ElicitRequest request) {
 		// Example implementation that returns synchronously but will be wrapped in Mono
 		return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("syncResponse",
 				"This was returned synchronously but wrapped in Mono", "requestMessage", request.message()));
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public Mono handleCancelElicitationRequest(ElicitRequest request) {
 		// Example implementation that cancels the request
 		return Mono.just(new ElicitResult(ElicitResult.Action.CANCEL, null));
@@ -48,27 +48,27 @@ public Mono handleCancelElicitationRequest(ElicitRequest request)
 
 	// Test methods for invalid scenarios
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public String invalidReturnType(ElicitRequest request) {
 		return "Invalid return type";
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public Mono invalidMonoReturnType(ElicitRequest request) {
 		return Mono.just("Invalid Mono return type");
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public Mono invalidParameterType(String request) {
 		return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")));
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public Mono noParameters() {
 		return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")));
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public Mono tooManyParameters(ElicitRequest request, String extra) {
 		return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")));
 	}
diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/ElicitationSpecificationTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/ElicitationSpecificationTests.java
new file mode 100644
index 0000000..4bee7f9
--- /dev/null
+++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/ElicitationSpecificationTests.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2025-2025 the original author or authors.
+ */
+
+package org.springaicommunity.mcp.method.elicitation;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
+import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
+import reactor.core.publisher.Mono;
+
+/**
+ * Tests for {@link SyncElicitationSpecification} and
+ * {@link AsyncElicitationSpecification} validation requirements.
+ *
+ * @author Christian Tzolov
+ */
+public class ElicitationSpecificationTests {
+
+	@Test
+	void testSyncElicitationSpecificationValidClientId() {
+		// Valid clientId should work
+		SyncElicitationSpecification spec = new SyncElicitationSpecification("valid-client-id",
+				request -> new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")));
+
+		assertThat(spec.clientId()).isEqualTo("valid-client-id");
+		assertThat(spec.elicitationHandler()).isNotNull();
+	}
+
+	@Test
+	void testSyncElicitationSpecificationNullClientId() {
+		assertThatThrownBy(() -> new SyncElicitationSpecification(null,
+				request -> new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"))))
+			.isInstanceOf(NullPointerException.class)
+			.hasMessage("clientId must not be null");
+	}
+
+	@Test
+	void testSyncElicitationSpecificationEmptyClientId() {
+		assertThatThrownBy(() -> new SyncElicitationSpecification("",
+				request -> new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"))))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientId must not be empty");
+	}
+
+	@Test
+	void testSyncElicitationSpecificationBlankClientId() {
+		assertThatThrownBy(() -> new SyncElicitationSpecification("   ",
+				request -> new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"))))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientId must not be empty");
+	}
+
+	@Test
+	void testSyncElicitationSpecificationNullHandler() {
+		assertThatThrownBy(() -> new SyncElicitationSpecification("valid-client-id", null))
+			.isInstanceOf(NullPointerException.class)
+			.hasMessage("elicitationHandler must not be null");
+	}
+
+	@Test
+	void testAsyncElicitationSpecificationValidClientId() {
+		// Valid clientId should work
+		AsyncElicitationSpecification spec = new AsyncElicitationSpecification("valid-client-id",
+				request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"))));
+
+		assertThat(spec.clientId()).isEqualTo("valid-client-id");
+		assertThat(spec.elicitationHandler()).isNotNull();
+	}
+
+	@Test
+	void testAsyncElicitationSpecificationNullClientId() {
+		assertThatThrownBy(() -> new AsyncElicitationSpecification(null,
+				request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")))))
+			.isInstanceOf(NullPointerException.class)
+			.hasMessage("clientId must not be null");
+	}
+
+	@Test
+	void testAsyncElicitationSpecificationEmptyClientId() {
+		assertThatThrownBy(() -> new AsyncElicitationSpecification("",
+				request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")))))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientId must not be empty");
+	}
+
+	@Test
+	void testAsyncElicitationSpecificationBlankClientId() {
+		assertThatThrownBy(() -> new AsyncElicitationSpecification("   ",
+				request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")))))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientId must not be empty");
+	}
+
+	@Test
+	void testAsyncElicitationSpecificationNullHandler() {
+		assertThatThrownBy(() -> new AsyncElicitationSpecification("valid-client-id", null))
+			.isInstanceOf(NullPointerException.class)
+			.hasMessage("elicitationHandler must not be null");
+	}
+
+	@Test
+	void testSyncElicitationSpecificationFunctionality() {
+		SyncElicitationSpecification spec = new SyncElicitationSpecification("test-client",
+				request -> new ElicitResult(ElicitResult.Action.ACCEPT,
+						Map.of("message", request.message(), "clientId", "test-client")));
+
+		ElicitRequest request = ElicitationTestHelper.createSampleRequest("Test message");
+		ElicitResult result = spec.elicitationHandler().apply(request);
+
+		assertThat(result).isNotNull();
+		assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
+		assertThat(result.content()).containsEntry("message", "Test message");
+		assertThat(result.content()).containsEntry("clientId", "test-client");
+	}
+
+	@Test
+	void testAsyncElicitationSpecificationFunctionality() {
+		AsyncElicitationSpecification spec = new AsyncElicitationSpecification("test-client",
+				request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT,
+						Map.of("message", request.message(), "clientId", "test-client"))));
+
+		ElicitRequest request = ElicitationTestHelper.createSampleRequest("Test async message");
+		Mono resultMono = spec.elicitationHandler().apply(request);
+
+		ElicitResult result = resultMono.block();
+		assertThat(result).isNotNull();
+		assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
+		assertThat(result.content()).containsEntry("message", "Test async message");
+		assertThat(result.content()).containsEntry("clientId", "test-client");
+	}
+
+}
diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallbackExample.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallbackExample.java
index e54ac34..6bcb0da 100644
--- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallbackExample.java
+++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallbackExample.java
@@ -18,20 +18,20 @@
  */
 public class SyncMcpElicitationMethodCallbackExample {
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public ElicitResult handleElicitationRequest(ElicitRequest request) {
 		// Example implementation that accepts the request and returns some content
 		return new ElicitResult(ElicitResult.Action.ACCEPT,
 				Map.of("userInput", "Example user input", "confirmed", true));
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public ElicitResult handleDeclineElicitationRequest(ElicitRequest request) {
 		// Example implementation that declines the request
 		return new ElicitResult(ElicitResult.Action.DECLINE, null);
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public ElicitResult handleCancelElicitationRequest(ElicitRequest request) {
 		// Example implementation that cancels the request
 		return new ElicitResult(ElicitResult.Action.CANCEL, null);
@@ -39,22 +39,22 @@ public ElicitResult handleCancelElicitationRequest(ElicitRequest request) {
 
 	// Test methods for invalid scenarios
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public String invalidReturnType(ElicitRequest request) {
 		return "Invalid return type";
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public ElicitResult invalidParameterType(String request) {
 		return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"));
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public ElicitResult noParameters() {
 		return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"));
 	}
 
-	@McpElicitation
+	@McpElicitation(clientId = "my-client-id")
 	public ElicitResult tooManyParameters(ElicitRequest request, String extra) {
 		return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"));
 	}
diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/elicitation/AsyncMcpElicitationProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/elicitation/AsyncMcpElicitationProviderTests.java
index bcf10d4..3269635 100644
--- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/elicitation/AsyncMcpElicitationProviderTests.java
+++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/elicitation/AsyncMcpElicitationProviderTests.java
@@ -69,7 +69,7 @@ public void testGetElicitationHandlerWithSyncMethod() {
 
 	public static class TestElicitationHandler {
 
-		@McpElicitation
+		@McpElicitation(clientId = "my-client-id")
 		public Mono handleElicitation(ElicitRequest request) {
 			return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT,
 					Map.of("name", "Async Test User", "message", request.message())));
@@ -79,7 +79,7 @@ public Mono handleElicitation(ElicitRequest request) {
 
 	public static class SyncElicitationHandler {
 
-		@McpElicitation
+		@McpElicitation(clientId = "my-client-id")
 		public ElicitResult handleElicitation(ElicitRequest request) {
 			return new ElicitResult(ElicitResult.Action.ACCEPT,
 					Map.of("name", "Sync Test User", "message", request.message()));
@@ -89,12 +89,12 @@ public ElicitResult handleElicitation(ElicitRequest request) {
 
 	public static class MultipleElicitationHandler {
 
-		@McpElicitation
+		@McpElicitation(clientId = "my-client-id")
 		public Mono handleElicitation1(ElicitRequest request) {
 			return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("handler", "1")));
 		}
 
-		@McpElicitation
+		@McpElicitation(clientId = "my-client-id")
 		public Mono handleElicitation2(ElicitRequest request) {
 			return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("handler", "2")));
 		}
diff --git a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/elicitation/SyncMcpElicitationProviderTests.java b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/elicitation/SyncMcpElicitationProviderTests.java
index 2306d75..d9bec15 100644
--- a/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/elicitation/SyncMcpElicitationProviderTests.java
+++ b/mcp-annotations/src/test/java/org/springaicommunity/mcp/provider/elicitation/SyncMcpElicitationProviderTests.java
@@ -46,7 +46,7 @@ public void testGetElicitationHandler() {
 
 	public static class TestElicitationHandler {
 
-		@McpElicitation
+		@McpElicitation(clientId = "my-client-id")
 		public ElicitResult handleElicitation(ElicitRequest request) {
 			return new ElicitResult(ElicitResult.Action.ACCEPT,
 					Map.of("name", "Test User", "message", request.message()));
@@ -56,12 +56,12 @@ public ElicitResult handleElicitation(ElicitRequest request) {
 
 	public static class MultipleElicitationHandler {
 
-		@McpElicitation
+		@McpElicitation(clientId = "my-client-id")
 		public ElicitResult handleElicitation1(ElicitRequest request) {
 			return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("handler", "1"));
 		}
 
-		@McpElicitation
+		@McpElicitation(clientId = "my-client-id")
 		public ElicitResult handleElicitation2(ElicitRequest request) {
 			return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("handler", "2"));
 		}