Skip to content

Commit ee776c0

Browse files
authored
feat!: make clientId required for @McpElicitation annotation (#37)
BREAKING CHANGE: The clientId parameter is now required for @McpElicitation annotation - Remove default empty string from McpElicitation.clientId parameter - Add validation in AsyncElicitationSpecification and SyncElicitationSpecification to ensure clientId is not null or empty - Update all test examples and documentation to include required clientId parameter - Update README.md to document the clientId requirement for @McpElicitation Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
1 parent a83dd56 commit ee776c0

File tree

9 files changed

+192
-32
lines changed

9 files changed

+192
-32
lines changed

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ The Spring integration module provides seamless integration with Spring AI and S
113113
#### Client
114114
- **`@McpLogging`** - Annotates methods that handle logging message notifications from MCP servers (requires `clientId` parameter)
115115
- **`@McpSampling`** - Annotates methods that handle sampling requests from MCP servers
116-
- **`@McpElicitation`** - Annotates methods that handle elicitation requests to gather additional information from users
116+
- **`@McpElicitation`** - Annotates methods that handle elicitation requests to gather additional information from users (requires `clientId` parameter)
117117
- **`@McpProgress`** - Annotates methods that handle progress notifications for long-running operations (requires `clientId` parameter)
118118
- **`@McpToolListChanged`** - Annotates methods that handle tool list change notifications from MCP servers
119119
- **`@McpResourceListChanged`** - Annotates methods that handle resource list change notifications from MCP servers
@@ -1579,10 +1579,11 @@ public class ElicitationHandler {
15791579

15801580
/**
15811581
* Handle elicitation requests with a synchronous implementation.
1582+
* Note: clientId is required for all @McpElicitation annotations.
15821583
* @param request The elicitation request
15831584
* @return The elicitation result
15841585
*/
1585-
@McpElicitation
1586+
@McpElicitation(clientId = "default-client")
15861587
public ElicitResult handleElicitationRequest(ElicitRequest request) {
15871588
// Example implementation that accepts the request and returns user data
15881589
// In a real implementation, this would present a form to the user
@@ -1616,10 +1617,11 @@ public class ElicitationHandler {
16161617

16171618
/**
16181619
* Handle elicitation requests that should be declined.
1620+
* Note: clientId is now required for all @McpElicitation annotations.
16191621
* @param request The elicitation request
16201622
* @return The elicitation result with decline action
16211623
*/
1622-
@McpElicitation
1624+
@McpElicitation(clientId = "default-client")
16231625
public ElicitResult handleDeclineElicitationRequest(ElicitRequest request) {
16241626
// Example of declining an elicitation request
16251627
return new ElicitResult(ElicitResult.Action.DECLINE, null);
@@ -1643,10 +1645,11 @@ public class AsyncElicitationHandler {
16431645

16441646
/**
16451647
* Handle elicitation requests with an asynchronous implementation.
1648+
* Note: clientId is required for all @McpElicitation annotations.
16461649
* @param request The elicitation request
16471650
* @return A Mono containing the elicitation result
16481651
*/
1649-
@McpElicitation
1652+
@McpElicitation(clientId = "default-client")
16501653
public Mono<ElicitResult> handleAsyncElicitationRequest(ElicitRequest request) {
16511654
return Mono.fromCallable(() -> {
16521655
// Simulate async processing of the elicitation request
@@ -1664,10 +1667,11 @@ public class AsyncElicitationHandler {
16641667

16651668
/**
16661669
* Handle elicitation requests that might be cancelled.
1670+
* Note: clientId is required for all @McpElicitation annotations.
16671671
* @param request The elicitation request
16681672
* @return A Mono containing the elicitation result with cancel action
16691673
*/
1670-
@McpElicitation
1674+
@McpElicitation(clientId = "default-client")
16711675
public Mono<ElicitResult> handleCancelElicitationRequest(ElicitRequest request) {
16721676
return Mono.just(new ElicitResult(ElicitResult.Action.CANCEL, null));
16731677
}

mcp-annotations/src/main/java/org/springaicommunity/mcp/annotation/McpElicitation.java

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
*
2525
* <p>
2626
* Example usage: <pre>{@code
27-
* &#64;McpElicitation
27+
* &#64;McpElicitation(clientId = "my-client-id")
2828
* public ElicitResult handleElicitationRequest(ElicitRequest request) {
2929
* return ElicitResult.builder()
3030
* .message("Generated response")
@@ -33,7 +33,7 @@
3333
* .build();
3434
* }
3535
*
36-
* &#64;McpElicitation
36+
* &#64;McpElicitation(clientId = "my-client-id")
3737
* public Mono<ElicitResult> handleAsyncElicitationRequest(ElicitRequest request) {
3838
* return Mono.just(ElicitResult.builder()
3939
* .message("Generated response")
@@ -54,8 +54,8 @@
5454

5555
/**
5656
* Used as connection or client identifier to select the MCP client, the elicitation
57-
* method is associated with. If not specified, is applied to all clients.
57+
* method is associated with.
5858
*/
59-
String clientId() default "";
59+
String clientId();
6060

6161
}

mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/AsyncElicitationSpecification.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package org.springaicommunity.mcp.method.elicitation;
66

7+
import java.util.Objects;
78
import java.util.function.Function;
89

910
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
@@ -13,4 +14,12 @@
1314
public record AsyncElicitationSpecification(String clientId,
1415
Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler) {
1516

17+
public AsyncElicitationSpecification {
18+
Objects.requireNonNull(clientId, "clientId must not be null");
19+
if (clientId.trim().isEmpty()) {
20+
throw new IllegalArgumentException("clientId must not be empty");
21+
}
22+
Objects.requireNonNull(elicitationHandler, "elicitationHandler must not be null");
23+
}
24+
1625
}

mcp-annotations/src/main/java/org/springaicommunity/mcp/method/elicitation/SyncElicitationSpecification.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@
44

55
package org.springaicommunity.mcp.method.elicitation;
66

7+
import java.util.Objects;
78
import java.util.function.Function;
89

910
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
1011
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
1112

1213
public record SyncElicitationSpecification(String clientId, Function<ElicitRequest, ElicitResult> elicitationHandler) {
14+
public SyncElicitationSpecification {
15+
Objects.requireNonNull(clientId, "clientId must not be null");
16+
if (clientId.trim().isEmpty()) {
17+
throw new IllegalArgumentException("clientId must not be empty");
18+
}
19+
Objects.requireNonNull(elicitationHandler, "elicitationHandler must not be null");
20+
}
1321

1422
}

mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/AsyncMcpElicitationMethodCallbackExample.java

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,56 +19,56 @@
1919
*/
2020
public class AsyncMcpElicitationMethodCallbackExample {
2121

22-
@McpElicitation
22+
@McpElicitation(clientId = "my-client-id")
2323
public Mono<ElicitResult> handleElicitationRequest(ElicitRequest request) {
2424
// Example implementation that accepts the request and returns some content
2525
return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("userInput", "Example async user input",
2626
"confirmed", true, "timestamp", System.currentTimeMillis())));
2727
}
2828

29-
@McpElicitation
29+
@McpElicitation(clientId = "my-client-id")
3030
public Mono<ElicitResult> handleDeclineElicitationRequest(ElicitRequest request) {
3131
// Example implementation that declines the request after a delay
3232
return Mono.delay(java.time.Duration.ofMillis(100))
3333
.then(Mono.just(new ElicitResult(ElicitResult.Action.DECLINE, null)));
3434
}
3535

36-
@McpElicitation
36+
@McpElicitation(clientId = "my-client-id")
3737
public ElicitResult handleSyncElicitationRequest(ElicitRequest request) {
3838
// Example implementation that returns synchronously but will be wrapped in Mono
3939
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("syncResponse",
4040
"This was returned synchronously but wrapped in Mono", "requestMessage", request.message()));
4141
}
4242

43-
@McpElicitation
43+
@McpElicitation(clientId = "my-client-id")
4444
public Mono<ElicitResult> handleCancelElicitationRequest(ElicitRequest request) {
4545
// Example implementation that cancels the request
4646
return Mono.just(new ElicitResult(ElicitResult.Action.CANCEL, null));
4747
}
4848

4949
// Test methods for invalid scenarios
5050

51-
@McpElicitation
51+
@McpElicitation(clientId = "my-client-id")
5252
public String invalidReturnType(ElicitRequest request) {
5353
return "Invalid return type";
5454
}
5555

56-
@McpElicitation
56+
@McpElicitation(clientId = "my-client-id")
5757
public Mono<String> invalidMonoReturnType(ElicitRequest request) {
5858
return Mono.just("Invalid Mono return type");
5959
}
6060

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

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

71-
@McpElicitation
71+
@McpElicitation(clientId = "my-client-id")
7272
public Mono<ElicitResult> tooManyParameters(ElicitRequest request, String extra) {
7373
return Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")));
7474
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*/
4+
5+
package org.springaicommunity.mcp.method.elicitation;
6+
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
9+
10+
import java.util.Map;
11+
12+
import org.junit.jupiter.api.Test;
13+
14+
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
15+
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
16+
import reactor.core.publisher.Mono;
17+
18+
/**
19+
* Tests for {@link SyncElicitationSpecification} and
20+
* {@link AsyncElicitationSpecification} validation requirements.
21+
*
22+
* @author Christian Tzolov
23+
*/
24+
public class ElicitationSpecificationTests {
25+
26+
@Test
27+
void testSyncElicitationSpecificationValidClientId() {
28+
// Valid clientId should work
29+
SyncElicitationSpecification spec = new SyncElicitationSpecification("valid-client-id",
30+
request -> new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")));
31+
32+
assertThat(spec.clientId()).isEqualTo("valid-client-id");
33+
assertThat(spec.elicitationHandler()).isNotNull();
34+
}
35+
36+
@Test
37+
void testSyncElicitationSpecificationNullClientId() {
38+
assertThatThrownBy(() -> new SyncElicitationSpecification(null,
39+
request -> new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"))))
40+
.isInstanceOf(NullPointerException.class)
41+
.hasMessage("clientId must not be null");
42+
}
43+
44+
@Test
45+
void testSyncElicitationSpecificationEmptyClientId() {
46+
assertThatThrownBy(() -> new SyncElicitationSpecification("",
47+
request -> new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"))))
48+
.isInstanceOf(IllegalArgumentException.class)
49+
.hasMessage("clientId must not be empty");
50+
}
51+
52+
@Test
53+
void testSyncElicitationSpecificationBlankClientId() {
54+
assertThatThrownBy(() -> new SyncElicitationSpecification(" ",
55+
request -> new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"))))
56+
.isInstanceOf(IllegalArgumentException.class)
57+
.hasMessage("clientId must not be empty");
58+
}
59+
60+
@Test
61+
void testSyncElicitationSpecificationNullHandler() {
62+
assertThatThrownBy(() -> new SyncElicitationSpecification("valid-client-id", null))
63+
.isInstanceOf(NullPointerException.class)
64+
.hasMessage("elicitationHandler must not be null");
65+
}
66+
67+
@Test
68+
void testAsyncElicitationSpecificationValidClientId() {
69+
// Valid clientId should work
70+
AsyncElicitationSpecification spec = new AsyncElicitationSpecification("valid-client-id",
71+
request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"))));
72+
73+
assertThat(spec.clientId()).isEqualTo("valid-client-id");
74+
assertThat(spec.elicitationHandler()).isNotNull();
75+
}
76+
77+
@Test
78+
void testAsyncElicitationSpecificationNullClientId() {
79+
assertThatThrownBy(() -> new AsyncElicitationSpecification(null,
80+
request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")))))
81+
.isInstanceOf(NullPointerException.class)
82+
.hasMessage("clientId must not be null");
83+
}
84+
85+
@Test
86+
void testAsyncElicitationSpecificationEmptyClientId() {
87+
assertThatThrownBy(() -> new AsyncElicitationSpecification("",
88+
request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")))))
89+
.isInstanceOf(IllegalArgumentException.class)
90+
.hasMessage("clientId must not be empty");
91+
}
92+
93+
@Test
94+
void testAsyncElicitationSpecificationBlankClientId() {
95+
assertThatThrownBy(() -> new AsyncElicitationSpecification(" ",
96+
request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value")))))
97+
.isInstanceOf(IllegalArgumentException.class)
98+
.hasMessage("clientId must not be empty");
99+
}
100+
101+
@Test
102+
void testAsyncElicitationSpecificationNullHandler() {
103+
assertThatThrownBy(() -> new AsyncElicitationSpecification("valid-client-id", null))
104+
.isInstanceOf(NullPointerException.class)
105+
.hasMessage("elicitationHandler must not be null");
106+
}
107+
108+
@Test
109+
void testSyncElicitationSpecificationFunctionality() {
110+
SyncElicitationSpecification spec = new SyncElicitationSpecification("test-client",
111+
request -> new ElicitResult(ElicitResult.Action.ACCEPT,
112+
Map.of("message", request.message(), "clientId", "test-client")));
113+
114+
ElicitRequest request = ElicitationTestHelper.createSampleRequest("Test message");
115+
ElicitResult result = spec.elicitationHandler().apply(request);
116+
117+
assertThat(result).isNotNull();
118+
assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
119+
assertThat(result.content()).containsEntry("message", "Test message");
120+
assertThat(result.content()).containsEntry("clientId", "test-client");
121+
}
122+
123+
@Test
124+
void testAsyncElicitationSpecificationFunctionality() {
125+
AsyncElicitationSpecification spec = new AsyncElicitationSpecification("test-client",
126+
request -> Mono.just(new ElicitResult(ElicitResult.Action.ACCEPT,
127+
Map.of("message", request.message(), "clientId", "test-client"))));
128+
129+
ElicitRequest request = ElicitationTestHelper.createSampleRequest("Test async message");
130+
Mono<ElicitResult> resultMono = spec.elicitationHandler().apply(request);
131+
132+
ElicitResult result = resultMono.block();
133+
assertThat(result).isNotNull();
134+
assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
135+
assertThat(result.content()).containsEntry("message", "Test async message");
136+
assertThat(result.content()).containsEntry("clientId", "test-client");
137+
}
138+
139+
}

mcp-annotations/src/test/java/org/springaicommunity/mcp/method/elicitation/SyncMcpElicitationMethodCallbackExample.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,43 +18,43 @@
1818
*/
1919
public class SyncMcpElicitationMethodCallbackExample {
2020

21-
@McpElicitation
21+
@McpElicitation(clientId = "my-client-id")
2222
public ElicitResult handleElicitationRequest(ElicitRequest request) {
2323
// Example implementation that accepts the request and returns some content
2424
return new ElicitResult(ElicitResult.Action.ACCEPT,
2525
Map.of("userInput", "Example user input", "confirmed", true));
2626
}
2727

28-
@McpElicitation
28+
@McpElicitation(clientId = "my-client-id")
2929
public ElicitResult handleDeclineElicitationRequest(ElicitRequest request) {
3030
// Example implementation that declines the request
3131
return new ElicitResult(ElicitResult.Action.DECLINE, null);
3232
}
3333

34-
@McpElicitation
34+
@McpElicitation(clientId = "my-client-id")
3535
public ElicitResult handleCancelElicitationRequest(ElicitRequest request) {
3636
// Example implementation that cancels the request
3737
return new ElicitResult(ElicitResult.Action.CANCEL, null);
3838
}
3939

4040
// Test methods for invalid scenarios
4141

42-
@McpElicitation
42+
@McpElicitation(clientId = "my-client-id")
4343
public String invalidReturnType(ElicitRequest request) {
4444
return "Invalid return type";
4545
}
4646

47-
@McpElicitation
47+
@McpElicitation(clientId = "my-client-id")
4848
public ElicitResult invalidParameterType(String request) {
4949
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"));
5050
}
5151

52-
@McpElicitation
52+
@McpElicitation(clientId = "my-client-id")
5353
public ElicitResult noParameters() {
5454
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"));
5555
}
5656

57-
@McpElicitation
57+
@McpElicitation(clientId = "my-client-id")
5858
public ElicitResult tooManyParameters(ElicitRequest request, String extra) {
5959
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("test", "value"));
6060
}

0 commit comments

Comments
 (0)