Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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<ElicitResult> handleAsyncElicitationRequest(ElicitRequest request) {
return Mono.fromCallable(() -> {
// Simulate async processing of the elicitation request
Expand All @@ -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<ElicitResult> handleCancelElicitationRequest(ElicitRequest request) {
return Mono.just(new ElicitResult(ElicitResult.Action.CANCEL, null));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
*
* <p>
* Example usage: <pre>{@code
* &#64;McpElicitation
* &#64;McpElicitation(clientId = "my-client-id")
* public ElicitResult handleElicitationRequest(ElicitRequest request) {
* return ElicitResult.builder()
* .message("Generated response")
Expand All @@ -33,7 +33,7 @@
* .build();
* }
*
* &#64;McpElicitation
* &#64;McpElicitation(clientId = "my-client-id")
* public Mono<ElicitResult> handleAsyncElicitationRequest(ElicitRequest request) {
* return Mono.just(ElicitResult.builder()
* .message("Generated response")
Expand All @@ -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();

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -13,4 +14,12 @@
public record AsyncElicitationSpecification(String clientId,
Function<ElicitRequest, Mono<ElicitResult>> 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");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ElicitRequest, ElicitResult> 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");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,56 +19,56 @@
*/
public class AsyncMcpElicitationMethodCallbackExample {

@McpElicitation
@McpElicitation(clientId = "my-client-id")
public Mono<ElicitResult> 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<ElicitResult> 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<ElicitResult> handleCancelElicitationRequest(ElicitRequest request) {
// Example implementation that cancels the request
return Mono.just(new ElicitResult(ElicitResult.Action.CANCEL, null));
}

// 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<String> invalidMonoReturnType(ElicitRequest request) {
return Mono.just("Invalid Mono return type");
}

@McpElicitation
@McpElicitation(clientId = "my-client-id")
public Mono<ElicitResult> invalidParameterType(String request) {
return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")));
}

@McpElicitation
@McpElicitation(clientId = "my-client-id")
public Mono<ElicitResult> noParameters() {
return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")));
}

@McpElicitation
@McpElicitation(clientId = "my-client-id")
public Mono<ElicitResult> tooManyParameters(ElicitRequest request, String extra) {
return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ElicitResult> 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");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,43 +18,43 @@
*/
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);
}

// 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"));
}
Expand Down
Loading