Skip to content

Commit c080686

Browse files
authored
refactor: extract JSON conversion utilities and enhance elicitation support (spring-ai-community#71)
- Extract convertMapToType methods to JsonParser utility class - Add convertObjectToMap method to JsonParser for bidirectional conversion - Add StructuredElicitResult support in elicitation method callbacks - Update AsyncMcpElicitationMethodCallback to handle Mono<StructuredElicitResult> - Update SyncMcpElicitationMethodCallback to handle StructuredElicitResult - Update providers to filter for StructuredElicitResult return types - Add StructuredElicitResultBuilder for fluent construction - Add percentage() convenience method to ProgressSpec - Add TODO comments for elicitation schema validation - Update method validation error messages for clarity - Remove unused Jackson imports from context classes - Update test expectations for new return type requirements Signed-off-by: Christian Tzolov <[email protected]>
1 parent 81c6362 commit c080686

File tree

10 files changed

+212
-79
lines changed

10 files changed

+212
-79
lines changed

mcp-annotations/src/main/java/org/springaicommunity/mcp/context/DefaultMcpAsyncRequestContext.java

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
import java.util.function.Consumer;
1010

1111
import com.fasterxml.jackson.core.type.TypeReference;
12-
import com.fasterxml.jackson.databind.JavaType;
13-
import com.fasterxml.jackson.databind.ObjectMapper;
1412
import io.modelcontextprotocol.common.McpTransportContext;
1513
import io.modelcontextprotocol.server.McpAsyncServerExchange;
1614
import io.modelcontextprotocol.spec.McpSchema;
@@ -79,7 +77,8 @@ public <T> Mono<StructuredElicitResult<T>> elicit(Consumer<ElicitationSpec> spec
7977
DefaultElicitationSpec elicitationSpec = new DefaultElicitationSpec();
8078
spec.accept(elicitationSpec);
8179
return this.elicitationInternal(elicitationSpec.message, type.getType(), elicitationSpec.meta)
82-
.map(er -> new StructuredElicitResult<T>(er.action(), convertMapToType(er.content(), type), er.meta()));
80+
.map(er -> new StructuredElicitResult<T>(er.action(), JsonParser.convertMapToType(er.content(), type),
81+
er.meta()));
8382
}
8483

8584
@Override
@@ -89,21 +88,24 @@ public <T> Mono<StructuredElicitResult<T>> elicit(Consumer<ElicitationSpec> spec
8988
DefaultElicitationSpec elicitationSpec = new DefaultElicitationSpec();
9089
spec.accept(elicitationSpec);
9190
return this.elicitationInternal(elicitationSpec.message, type, elicitationSpec.meta)
92-
.map(er -> new StructuredElicitResult<T>(er.action(), convertMapToType(er.content(), type), er.meta()));
91+
.map(er -> new StructuredElicitResult<T>(er.action(), JsonParser.convertMapToType(er.content(), type),
92+
er.meta()));
9393
}
9494

9595
@Override
9696
public <T> Mono<StructuredElicitResult<T>> elicit(TypeReference<T> type) {
9797
Assert.notNull(type, "Elicitation response type must not be null");
9898
return this.elicitationInternal("Please provide the required information.", type.getType(), null)
99-
.map(er -> new StructuredElicitResult<T>(er.action(), convertMapToType(er.content(), type), er.meta()));
99+
.map(er -> new StructuredElicitResult<T>(er.action(), JsonParser.convertMapToType(er.content(), type),
100+
er.meta()));
100101
}
101102

102103
@Override
103104
public <T> Mono<StructuredElicitResult<T>> elicit(Class<T> type) {
104105
Assert.notNull(type, "Elicitation response type must not be null");
105106
return this.elicitationInternal("Please provide the required information.", type, null)
106-
.map(er -> new StructuredElicitResult<T>(er.action(), convertMapToType(er.content(), type), er.meta()));
107+
.map(er -> new StructuredElicitResult<T>(er.action(), JsonParser.convertMapToType(er.content(), type),
108+
er.meta()));
107109
}
108110

109111
@Override
@@ -124,6 +126,9 @@ public Mono<ElicitResult> elicitationInternal(String message, Type type, Map<Str
124126
Assert.hasText(message, "Elicitation message must not be empty");
125127
Assert.notNull(type, "Elicitation response type must not be null");
126128

129+
// TODO add validation for the Elicitation Schema
130+
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#supported-schema-types
131+
127132
Map<String, Object> schema = typeSchemaCache.computeIfAbsent(type, t -> this.generateElicitSchema(t));
128133

129134
return this.elicit(ElicitRequest.builder().message(message).requestedSchema(schema).meta(meta).build());
@@ -136,18 +141,6 @@ private Map<String, Object> generateElicitSchema(Type type) {
136141
return schema;
137142
}
138143

139-
private static <T> T convertMapToType(Map<String, Object> map, Class<T> targetType) {
140-
ObjectMapper mapper = new ObjectMapper();
141-
JavaType javaType = mapper.getTypeFactory().constructType(targetType);
142-
return mapper.convertValue(map, javaType);
143-
}
144-
145-
private static <T> T convertMapToType(Map<String, Object> map, TypeReference<T> targetType) {
146-
ObjectMapper mapper = new ObjectMapper();
147-
JavaType javaType = mapper.getTypeFactory().constructType(targetType);
148-
return mapper.convertValue(map, javaType);
149-
}
150-
151144
// Sampling
152145

153146
@Override

mcp-annotations/src/main/java/org/springaicommunity/mcp/context/DefaultMcpSyncRequestContext.java

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
import java.util.function.Consumer;
1111

1212
import com.fasterxml.jackson.core.type.TypeReference;
13-
import com.fasterxml.jackson.databind.JavaType;
14-
import com.fasterxml.jackson.databind.ObjectMapper;
1513
import io.modelcontextprotocol.common.McpTransportContext;
1614
import io.modelcontextprotocol.server.McpSyncServerExchange;
1715
import io.modelcontextprotocol.spec.McpSchema;
@@ -80,7 +78,7 @@ public <T> Optional<StructuredElicitResult<T>> elicit(Class<T> type) {
8078
}
8179

8280
return Optional.of(new StructuredElicitResult<>(elicitResult.get().action(),
83-
convertMapToType(elicitResult.get().content(), type), elicitResult.get().meta()));
81+
JsonParser.convertMapToType(elicitResult.get().content(), type), elicitResult.get().meta()));
8482
}
8583

8684
@Override
@@ -95,7 +93,7 @@ public <T> Optional<StructuredElicitResult<T>> elicit(TypeReference<T> type) {
9593
}
9694

9795
return Optional.of(new StructuredElicitResult<>(elicitResult.get().action(),
98-
convertMapToType(elicitResult.get().content(), type), elicitResult.get().meta()));
96+
JsonParser.convertMapToType(elicitResult.get().content(), type), elicitResult.get().meta()));
9997
}
10098

10199
@Override
@@ -114,7 +112,7 @@ public <T> Optional<StructuredElicitResult<T>> elicit(Consumer<ElicitationSpec>
114112
}
115113

116114
return Optional.of(new StructuredElicitResult<>(elicitResult.get().action(),
117-
convertMapToType(elicitResult.get().content(), returnType), elicitResult.get().meta()));
115+
JsonParser.convertMapToType(elicitResult.get().content(), returnType), elicitResult.get().meta()));
118116
}
119117

120118
@Override
@@ -134,7 +132,7 @@ public <T> Optional<StructuredElicitResult<T>> elicit(Consumer<ElicitationSpec>
134132
}
135133

136134
return Optional.of(new StructuredElicitResult<>(elicitResult.get().action(),
137-
convertMapToType(elicitResult.get().content(), returnType), elicitResult.get().meta()));
135+
JsonParser.convertMapToType(elicitResult.get().content(), returnType), elicitResult.get().meta()));
138136
}
139137

140138
@Override
@@ -157,6 +155,9 @@ private Optional<ElicitResult> elicitationInternal(String message, Type type, Ma
157155
Assert.hasText(message, "Elicitation message must not be empty");
158156
Assert.notNull(type, "Elicitation response type must not be null");
159157

158+
// TODO add validation for the Elicitation Schema
159+
// https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#supported-schema-types
160+
160161
Map<String, Object> schema = typeSchemaCache.computeIfAbsent(type, t -> this.generateElicitSchema(t));
161162

162163
return this.elicit(ElicitRequest.builder().message(message).requestedSchema(schema).meta(meta).build());
@@ -169,18 +170,6 @@ private Map<String, Object> generateElicitSchema(Type type) {
169170
return schema;
170171
}
171172

172-
private static <T> T convertMapToType(Map<String, Object> map, Class<T> targetType) {
173-
ObjectMapper mapper = new ObjectMapper();
174-
JavaType javaType = mapper.getTypeFactory().constructType(targetType);
175-
return mapper.convertValue(map, javaType);
176-
}
177-
178-
private static <T> T convertMapToType(Map<String, Object> map, TypeReference<T> targetType) {
179-
ObjectMapper mapper = new ObjectMapper();
180-
JavaType javaType = mapper.getTypeFactory().constructType(targetType);
181-
return mapper.convertValue(map, javaType);
182-
}
183-
184173
// Sampling
185174

186175
@Override

mcp-annotations/src/main/java/org/springaicommunity/mcp/context/McpRequestContextTypes.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import io.modelcontextprotocol.spec.McpSchema.ResourceLink;
2121
import io.modelcontextprotocol.spec.McpSchema.SamplingMessage;
2222
import io.modelcontextprotocol.spec.McpSchema.TextContent;
23+
import io.modelcontextprotocol.util.Assert;
2324

2425
/**
2526
* @author Christian Tzolov
@@ -110,6 +111,11 @@ interface ProgressSpec {
110111

111112
ProgressSpec meta(String k, Object v);
112113

114+
default ProgressSpec percentage(int percentage) {
115+
Assert.isTrue(percentage >= 0 && percentage <= 100, "Percentage must be between 0 and 100");
116+
return this.progress(percentage).total(100.0);
117+
}
118+
113119
}
114120

115121
// --------------------------------------
@@ -143,6 +149,7 @@ interface LoggingSpec {
143149

144150
ClientCapabilities clientCapabilities();
145151

152+
// TODO: Should we rename it to meta()?
146153
Map<String, Object> requestMeta();
147154

148155
McpTransportContext transportContext();

mcp-annotations/src/main/java/org/springaicommunity/mcp/context/McpSyncRequestContext.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ public interface McpSyncRequestContext extends McpRequestContextTypes<McpSyncSer
5151
// --------------------------------------
5252
// Progress
5353
// --------------------------------------
54-
void progress(int progress);
54+
void progress(int percentage);
5555

5656
void progress(Consumer<ProgressSpec> progressSpec);
5757

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*/
4+
5+
package org.springaicommunity.mcp.context;
6+
7+
import java.util.HashMap;
8+
import java.util.Map;
9+
10+
import io.modelcontextprotocol.spec.McpSchema.ElicitResult.Action;
11+
import io.modelcontextprotocol.util.Assert;
12+
13+
/**
14+
* Builder for {@link StructuredElicitResult}.
15+
*
16+
* @param <T> the type of the structured content
17+
* @author Christian Tzolov
18+
*/
19+
public class StructuredElicitResultBuilder<T> {
20+
21+
private Action action = Action.ACCEPT;
22+
23+
private T structuredContent;
24+
25+
private Map<String, Object> meta = new HashMap<>();
26+
27+
/**
28+
* Private constructor to enforce builder pattern usage.
29+
*/
30+
private StructuredElicitResultBuilder() {
31+
this.meta = new HashMap<>();
32+
}
33+
34+
/**
35+
* Creates a new builder instance.
36+
* @param <T> the type of the structured content
37+
* @return a new builder instance
38+
*/
39+
public static <T> StructuredElicitResultBuilder<T> builder() {
40+
return new StructuredElicitResultBuilder<>();
41+
}
42+
43+
/**
44+
* Sets the action.
45+
* @param action the action to set
46+
* @return this builder instance
47+
*/
48+
public StructuredElicitResultBuilder<T> action(Action action) {
49+
Assert.notNull(action, "Action must not be null");
50+
this.action = action;
51+
return this;
52+
}
53+
54+
/**
55+
* Sets the structured content.
56+
* @param structuredContent the structured content to set
57+
* @return this builder instance
58+
*/
59+
public StructuredElicitResultBuilder<T> structuredContent(T structuredContent) {
60+
this.structuredContent = structuredContent;
61+
return this;
62+
}
63+
64+
/**
65+
* Sets the meta map.
66+
* @param meta the meta map to set
67+
* @return this builder instance
68+
*/
69+
public StructuredElicitResultBuilder<T> meta(Map<String, Object> meta) {
70+
this.meta = meta != null ? new HashMap<>(meta) : new HashMap<>();
71+
return this;
72+
}
73+
74+
/**
75+
* Adds a single meta entry.
76+
* @param key the meta key
77+
* @param value the meta value
78+
* @return this builder instance
79+
*/
80+
public StructuredElicitResultBuilder<T> addMeta(String key, Object value) {
81+
this.meta.put(key, value);
82+
return this;
83+
}
84+
85+
/**
86+
* Builds the {@link StructuredElicitResult} instance.
87+
* @return a new StructuredElicitResult instance
88+
*/
89+
public StructuredElicitResult<T> build() {
90+
return new StructuredElicitResult<>(this.action, this.structuredContent, this.meta);
91+
}
92+
93+
}

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

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
import java.util.function.Function;
99

1010
import org.springaicommunity.mcp.annotation.McpElicitation;
11-
11+
import org.springaicommunity.mcp.context.StructuredElicitResult;
12+
import org.springaicommunity.mcp.method.tool.utils.JsonParser;
1213
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
1314
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
1415
import reactor.core.publisher.Mono;
@@ -56,19 +57,34 @@ public Mono<ElicitResult> apply(ElicitRequest request) {
5657

5758
// If the method returns a Mono, handle it
5859
if (result instanceof Mono) {
59-
@SuppressWarnings("unchecked")
60-
Mono<ElicitResult> monoResult = (Mono<ElicitResult>) result;
61-
return monoResult;
62-
}
63-
// If the method returns an ElicitResult directly, wrap it in a Mono
64-
else if (result instanceof ElicitResult) {
65-
return Mono.just((ElicitResult) result);
60+
Mono<?> monoResult = (Mono<?>) result;
61+
return monoResult.flatMap(value -> {
62+
if (value instanceof StructuredElicitResult) {
63+
StructuredElicitResult<?> structuredElicitResult = (StructuredElicitResult<?>) value;
64+
65+
var content = structuredElicitResult.structuredContent() != null
66+
? JsonParser.convertObjectToMap(structuredElicitResult.structuredContent()) : null;
67+
68+
return Mono.just(ElicitResult.builder()
69+
.message(structuredElicitResult.action())
70+
.content(content)
71+
.meta(structuredElicitResult.meta())
72+
.build());
73+
74+
}
75+
else if (value instanceof ElicitResult) {
76+
return Mono.just((ElicitResult) value);
77+
}
78+
79+
return Mono.error(new McpElicitationMethodException(
80+
"Method must return Mono<ElicitResult> or Mono<StructuredElicitResult>: "
81+
+ this.method.getName()));
82+
83+
});
6684
}
6785
// Otherwise, throw an exception
68-
else {
69-
return Mono.error(new McpElicitationMethodException(
70-
"Method must return Mono<ElicitResult> or ElicitResult: " + this.method.getName()));
71-
}
86+
return Mono.error(new McpElicitationMethodException(
87+
"Method must return Mono<ElicitResult> or Mono<StructuredElicitResult>: " + this.method.getName()));
7288
}
7389
catch (Exception e) {
7490
return Mono.error(new McpElicitationMethodException(
@@ -85,10 +101,10 @@ else if (result instanceof ElicitResult) {
85101
protected void validateReturnType(Method method) {
86102
Class<?> returnType = method.getReturnType();
87103

88-
if (!Mono.class.isAssignableFrom(returnType) && !ElicitResult.class.isAssignableFrom(returnType)) {
104+
if (!Mono.class.isAssignableFrom(returnType)) {
89105
throw new IllegalArgumentException(
90-
"Method must return Mono<ElicitResult> or ElicitResult: " + method.getName() + " in "
91-
+ method.getDeclaringClass().getName() + " returns " + returnType.getName());
106+
"Method must return Mono<ElicitResult> or Mono<StructuredElicitResult>: " + method.getName()
107+
+ " in " + method.getDeclaringClass().getName() + " returns " + returnType.getName());
92108
}
93109
}
94110

0 commit comments

Comments
 (0)