Skip to content

Commit 7e4304f

Browse files
committed
feat: Add support for MCP list changed notifications with enhanced validation
* Add support for tool, resource, and prompt list changed notifications - New AsyncToolListChangedSpecification, AsyncResourceListChangedSpecification, AsyncPromptListChangedSpecification - New SyncToolListChangedSpecification, SyncResourceListChangedSpecification, SyncPromptListChangedSpecification - Wire list changed specifications in MCP client auto-configuration * Enhance annotation customizers with validation and logging - Prevent duplicate elicitation and sampling specs per client with proper error handling - Add comprehensive logging for all registered MCP client specifications - Track registered specifications per client using ConcurrentHashMap * Add comprehensive unit test suite - Complete test coverage for McpSyncAnnotationCustomizer - Test duplicate validation, case-insensitive matching, and error scenarios * Update documentation with breaking changes - MCP client annotations now require mandatory clientId parameter - Update all examples to include clientId in annotation usage - Add configuration examples showing clientId mapping to connection names Signed-off-by: Christian Tzolov <[email protected]>
1 parent 1f18087 commit 7e4304f

File tree

6 files changed

+590
-38
lines changed

6 files changed

+590
-38
lines changed

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
import io.modelcontextprotocol.client.McpSyncClient;
2525
import io.modelcontextprotocol.spec.McpSchema;
2626

27+
import org.springaicommunity.mcp.method.changed.prompt.AsyncPromptListChangedSpecification;
28+
import org.springaicommunity.mcp.method.changed.prompt.SyncPromptListChangedSpecification;
29+
import org.springaicommunity.mcp.method.changed.resource.AsyncResourceListChangedSpecification;
30+
import org.springaicommunity.mcp.method.changed.resource.SyncResourceListChangedSpecification;
31+
import org.springaicommunity.mcp.method.changed.tool.AsyncToolListChangedSpecification;
32+
import org.springaicommunity.mcp.method.changed.tool.SyncToolListChangedSpecification;
2733
import org.springaicommunity.mcp.method.elicitation.AsyncElicitationSpecification;
2834
import org.springaicommunity.mcp.method.elicitation.SyncElicitationSpecification;
2935
import org.springaicommunity.mcp.method.logging.AsyncLoggingSpecification;
@@ -223,8 +229,13 @@ McpSyncClientConfigurer mcpSyncClientConfigurer(ObjectProvider<McpSyncClientCust
223229
matchIfMissing = true)
224230
public McpSyncClientCustomizer mcpAnnotationMcpSyncClientCustomizer(List<SyncLoggingSpecification> loggingSpecs,
225231
List<SyncSamplingSpecification> samplingSpecs, List<SyncElicitationSpecification> elicitationSpecs,
226-
List<SyncProgressSpecification> progressSpecs) {
227-
return new McpSyncAnnotationCustomizer(samplingSpecs, loggingSpecs, elicitationSpecs, progressSpecs);
232+
List<SyncProgressSpecification> progressSpecs,
233+
List<SyncToolListChangedSpecification> syncToolListChangedSpecifications,
234+
List<SyncResourceListChangedSpecification> syncResourceListChangedSpecifications,
235+
List<SyncPromptListChangedSpecification> syncPromptListChangedSpecifications) {
236+
return new McpSyncAnnotationCustomizer(samplingSpecs, loggingSpecs, elicitationSpecs, progressSpecs,
237+
syncToolListChangedSpecifications, syncResourceListChangedSpecifications,
238+
syncPromptListChangedSpecifications);
228239
}
229240

230241
// Async client configuration
@@ -282,8 +293,12 @@ McpAsyncClientConfigurer mcpAsyncClientConfigurer(ObjectProvider<McpAsyncClientC
282293
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC")
283294
public McpAsyncClientCustomizer mcpAnnotationMcpAsyncClientCustomizer(List<AsyncLoggingSpecification> loggingSpecs,
284295
List<AsyncSamplingSpecification> samplingSpecs, List<AsyncElicitationSpecification> elicitationSpecs,
285-
List<AsyncProgressSpecification> progressSpecs) {
286-
return new McpAsyncAnnotationCustomizer(samplingSpecs, loggingSpecs, elicitationSpecs, progressSpecs);
296+
List<AsyncProgressSpecification> progressSpecs,
297+
List<AsyncToolListChangedSpecification> toolListChangedSpecs,
298+
List<AsyncResourceListChangedSpecification> resourceListChangedSpecs,
299+
List<AsyncPromptListChangedSpecification> promptListChangedSpecs) {
300+
return new McpAsyncAnnotationCustomizer(samplingSpecs, loggingSpecs, elicitationSpecs, progressSpecs,
301+
toolListChangedSpecs, resourceListChangedSpecs, promptListChangedSpecs);
287302
}
288303

289304
/**

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpAsyncAnnotationCustomizer.java

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@
1717
package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
1818

1919
import java.util.List;
20-
20+
import java.util.Map;
21+
import java.util.concurrent.ConcurrentHashMap;
22+
23+
import org.slf4j.Logger;
24+
import org.slf4j.LoggerFactory;
25+
import org.springaicommunity.mcp.method.changed.prompt.AsyncPromptListChangedSpecification;
26+
import org.springaicommunity.mcp.method.changed.resource.AsyncResourceListChangedSpecification;
27+
import org.springaicommunity.mcp.method.changed.tool.AsyncToolListChangedSpecification;
2128
import org.springaicommunity.mcp.method.elicitation.AsyncElicitationSpecification;
2229
import org.springaicommunity.mcp.method.logging.AsyncLoggingSpecification;
2330
import org.springaicommunity.mcp.method.progress.AsyncProgressSpecification;
@@ -32,6 +39,8 @@
3239
*/
3340
public class McpAsyncAnnotationCustomizer implements McpAsyncClientCustomizer {
3441

42+
private static final Logger logger = LoggerFactory.getLogger(McpAsyncAnnotationCustomizer.class);
43+
3544
private final List<AsyncSamplingSpecification> asyncSamplingSpecifications;
3645

3746
private final List<AsyncLoggingSpecification> asyncLoggingSpecifications;
@@ -40,15 +49,32 @@ public class McpAsyncAnnotationCustomizer implements McpAsyncClientCustomizer {
4049

4150
private final List<AsyncProgressSpecification> asyncProgressSpecifications;
4251

52+
private final List<AsyncToolListChangedSpecification> asyncToolListChangedSpecifications;
53+
54+
private final List<AsyncResourceListChangedSpecification> asyncResourceListChangedSpecifications;
55+
56+
private final List<AsyncPromptListChangedSpecification> asyncPromptListChangedSpecifications;
57+
58+
// Tracking registered specifications per client
59+
private final Map<String, Boolean> clientElicitationSpecs = new ConcurrentHashMap<>();
60+
61+
private final Map<String, Boolean> clientSamplingSpecs = new ConcurrentHashMap<>();
62+
4363
public McpAsyncAnnotationCustomizer(List<AsyncSamplingSpecification> asyncSamplingSpecifications,
4464
List<AsyncLoggingSpecification> asyncLoggingSpecifications,
4565
List<AsyncElicitationSpecification> asyncElicitationSpecifications,
46-
List<AsyncProgressSpecification> asyncProgressSpecifications) {
66+
List<AsyncProgressSpecification> asyncProgressSpecifications,
67+
List<AsyncToolListChangedSpecification> asyncToolListChangedSpecifications,
68+
List<AsyncResourceListChangedSpecification> asyncResourceListChangedSpecifications,
69+
List<AsyncPromptListChangedSpecification> asyncPromptListChangedSpecifications) {
4770

4871
this.asyncSamplingSpecifications = asyncSamplingSpecifications;
4972
this.asyncLoggingSpecifications = asyncLoggingSpecifications;
5073
this.asyncElicitationSpecifications = asyncElicitationSpecifications;
5174
this.asyncProgressSpecifications = asyncProgressSpecifications;
75+
this.asyncToolListChangedSpecifications = asyncToolListChangedSpecifications;
76+
this.asyncResourceListChangedSpecifications = asyncResourceListChangedSpecifications;
77+
this.asyncPromptListChangedSpecifications = asyncPromptListChangedSpecifications;
5278
}
5379

5480
@Override
@@ -57,15 +83,36 @@ public void customize(String name, AsyncSpec clientSpec) {
5783
if (!CollectionUtils.isEmpty(asyncElicitationSpecifications)) {
5884
this.asyncElicitationSpecifications.forEach(elicitationSpec -> {
5985
if (elicitationSpec.clientId().equalsIgnoreCase(name)) {
86+
87+
// Check if client already has an elicitation spec
88+
if (clientElicitationSpecs.containsKey(name)) {
89+
throw new IllegalArgumentException("Client '" + name
90+
+ "' already has an elicitationSpec registered. Only one elicitationSpec is allowed per client.");
91+
}
92+
93+
clientElicitationSpecs.put(name, Boolean.TRUE);
6094
clientSpec.elicitation(elicitationSpec.elicitationHandler());
95+
96+
logger.info("Registered elicitationSpec for client '{}'.", name);
97+
6198
}
6299
});
63100
}
64101

65102
if (!CollectionUtils.isEmpty(asyncSamplingSpecifications)) {
66103
this.asyncSamplingSpecifications.forEach(samplingSpec -> {
67104
if (samplingSpec.clientId().equalsIgnoreCase(name)) {
105+
106+
// Check if client already has a sampling spec
107+
if (clientSamplingSpecs.containsKey(name)) {
108+
throw new IllegalArgumentException("Client '" + name
109+
+ "' already has a samplingSpec registered. Only one samplingSpec is allowed per client.");
110+
}
111+
clientSamplingSpecs.put(name, Boolean.TRUE);
112+
68113
clientSpec.sampling(samplingSpec.samplingHandler());
114+
115+
logger.info("Registered samplingSpec for client '{}'.", name);
69116
}
70117
});
71118
}
@@ -74,6 +121,7 @@ public void customize(String name, AsyncSpec clientSpec) {
74121
this.asyncLoggingSpecifications.forEach(loggingSpec -> {
75122
if (loggingSpec.clientId().equalsIgnoreCase(name)) {
76123
clientSpec.loggingConsumer(loggingSpec.loggingHandler());
124+
logger.info("Registered loggingSpec for client '{}'.", name);
77125
}
78126
});
79127
}
@@ -82,6 +130,34 @@ public void customize(String name, AsyncSpec clientSpec) {
82130
this.asyncProgressSpecifications.forEach(progressSpec -> {
83131
if (progressSpec.clientId().equalsIgnoreCase(name)) {
84132
clientSpec.progressConsumer(progressSpec.progressHandler());
133+
logger.info("Registered progressSpec for client '{}'.", name);
134+
}
135+
});
136+
}
137+
138+
if (!CollectionUtils.isEmpty(asyncToolListChangedSpecifications)) {
139+
this.asyncToolListChangedSpecifications.forEach(toolListChangedSpec -> {
140+
if (toolListChangedSpec.clientId().equalsIgnoreCase(name)) {
141+
clientSpec.toolsChangeConsumer(toolListChangedSpec.toolListChangeHandler());
142+
logger.info("Registered toolListChangedSpec for client '{}'.", name);
143+
}
144+
});
145+
}
146+
147+
if (!CollectionUtils.isEmpty(asyncResourceListChangedSpecifications)) {
148+
this.asyncResourceListChangedSpecifications.forEach(resourceListChangedSpec -> {
149+
if (resourceListChangedSpec.clientId().equalsIgnoreCase(name)) {
150+
clientSpec.resourcesChangeConsumer(resourceListChangedSpec.resourceListChangeHandler());
151+
logger.info("Registered resourceListChangedSpec for client '{}'.", name);
152+
}
153+
});
154+
}
155+
156+
if (!CollectionUtils.isEmpty(asyncPromptListChangedSpecifications)) {
157+
this.asyncPromptListChangedSpecifications.forEach(promptListChangedSpec -> {
158+
if (promptListChangedSpec.clientId().equalsIgnoreCase(name)) {
159+
clientSpec.promptsChangeConsumer(promptListChangedSpec.promptListChangeHandler());
160+
logger.info("Registered promptListChangedSpec for client '{}'.", name);
85161
}
86162
});
87163
}

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/annotations/McpSyncAnnotationCustomizer.java

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@
1717
package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
1818

1919
import java.util.List;
20+
import java.util.Map;
2021
import java.util.concurrent.ConcurrentHashMap;
2122

23+
import org.slf4j.Logger;
24+
import org.slf4j.LoggerFactory;
25+
import org.springaicommunity.mcp.method.changed.prompt.SyncPromptListChangedSpecification;
26+
import org.springaicommunity.mcp.method.changed.resource.SyncResourceListChangedSpecification;
27+
import org.springaicommunity.mcp.method.changed.tool.SyncToolListChangedSpecification;
2228
import org.springaicommunity.mcp.method.elicitation.SyncElicitationSpecification;
2329
import org.springaicommunity.mcp.method.logging.SyncLoggingSpecification;
2430
import org.springaicommunity.mcp.method.progress.SyncProgressSpecification;
@@ -33,6 +39,8 @@
3339
*/
3440
public class McpSyncAnnotationCustomizer implements McpSyncClientCustomizer {
3541

42+
private static final Logger logger = LoggerFactory.getLogger(McpSyncAnnotationCustomizer.class);
43+
3644
private final List<SyncSamplingSpecification> syncSamplingSpecifications;
3745

3846
private final List<SyncLoggingSpecification> syncLoggingSpecifications;
@@ -41,15 +49,32 @@ public class McpSyncAnnotationCustomizer implements McpSyncClientCustomizer {
4149

4250
private final List<SyncProgressSpecification> syncProgressSpecifications;
4351

52+
private final List<SyncToolListChangedSpecification> syncToolListChangedSpecifications;
53+
54+
private final List<SyncResourceListChangedSpecification> syncResourceListChangedSpecifications;
55+
56+
private final List<SyncPromptListChangedSpecification> syncPromptListChangedSpecifications;
57+
58+
// Tracking registered specifications per client
59+
private final Map<String, Boolean> clientElicitationSpecs = new ConcurrentHashMap<>();
60+
61+
private final Map<String, Boolean> clientSamplingSpecs = new ConcurrentHashMap<>();
62+
4463
public McpSyncAnnotationCustomizer(List<SyncSamplingSpecification> syncSamplingSpecifications,
4564
List<SyncLoggingSpecification> syncLoggingSpecifications,
4665
List<SyncElicitationSpecification> syncElicitationSpecifications,
47-
List<SyncProgressSpecification> syncProgressSpecifications) {
66+
List<SyncProgressSpecification> syncProgressSpecifications,
67+
List<SyncToolListChangedSpecification> syncToolListChangedSpecifications,
68+
List<SyncResourceListChangedSpecification> syncResourceListChangedSpecifications,
69+
List<SyncPromptListChangedSpecification> syncPromptListChangedSpecifications) {
4870

4971
this.syncSamplingSpecifications = syncSamplingSpecifications;
5072
this.syncLoggingSpecifications = syncLoggingSpecifications;
5173
this.syncElicitationSpecifications = syncElicitationSpecifications;
5274
this.syncProgressSpecifications = syncProgressSpecifications;
75+
this.syncToolListChangedSpecifications = syncToolListChangedSpecifications;
76+
this.syncResourceListChangedSpecifications = syncResourceListChangedSpecifications;
77+
this.syncPromptListChangedSpecifications = syncPromptListChangedSpecifications;
5378
}
5479

5580
@Override
@@ -58,15 +83,36 @@ public void customize(String name, SyncSpec clientSpec) {
5883
if (!CollectionUtils.isEmpty(syncElicitationSpecifications)) {
5984
this.syncElicitationSpecifications.forEach(elicitationSpec -> {
6085
if (elicitationSpec.clientId().equalsIgnoreCase(name)) {
86+
87+
// Check if client already has an elicitation spec
88+
if (clientElicitationSpecs.containsKey(name)) {
89+
throw new IllegalArgumentException("Client '" + name
90+
+ "' already has an elicitationSpec registered. Only one elicitationSpec is allowed per client.");
91+
}
92+
93+
clientElicitationSpecs.put(name, Boolean.TRUE);
6194
clientSpec.elicitation(elicitationSpec.elicitationHandler());
95+
96+
logger.info("Registered elicitationSpec for client '{}'.", name);
97+
6298
}
6399
});
64100
}
65101

66102
if (!CollectionUtils.isEmpty(syncSamplingSpecifications)) {
67103
this.syncSamplingSpecifications.forEach(samplingSpec -> {
68104
if (samplingSpec.clientId().equalsIgnoreCase(name)) {
105+
106+
// Check if client already has a sampling spec
107+
if (clientSamplingSpecs.containsKey(name)) {
108+
throw new IllegalArgumentException("Client '" + name
109+
+ "' already has a samplingSpec registered. Only one samplingSpec is allowed per client.");
110+
}
111+
clientSamplingSpecs.put(name, Boolean.TRUE);
112+
69113
clientSpec.sampling(samplingSpec.samplingHandler());
114+
115+
logger.info("Registered samplingSpec for client '{}'.", name);
70116
}
71117
});
72118
}
@@ -75,6 +121,7 @@ public void customize(String name, SyncSpec clientSpec) {
75121
this.syncLoggingSpecifications.forEach(loggingSpec -> {
76122
if (loggingSpec.clientId().equalsIgnoreCase(name)) {
77123
clientSpec.loggingConsumer(loggingSpec.loggingHandler());
124+
logger.info("Registered loggingSpec for client '{}'.", name);
78125
}
79126
});
80127
}
@@ -83,6 +130,34 @@ public void customize(String name, SyncSpec clientSpec) {
83130
this.syncProgressSpecifications.forEach(progressSpec -> {
84131
if (progressSpec.clientId().equalsIgnoreCase(name)) {
85132
clientSpec.progressConsumer(progressSpec.progressHandler());
133+
logger.info("Registered progressSpec for client '{}'.", name);
134+
}
135+
});
136+
}
137+
138+
if (!CollectionUtils.isEmpty(syncToolListChangedSpecifications)) {
139+
this.syncToolListChangedSpecifications.forEach(toolListChangedSpec -> {
140+
if (toolListChangedSpec.clientId().equalsIgnoreCase(name)) {
141+
clientSpec.toolsChangeConsumer(toolListChangedSpec.toolListChangeHandler());
142+
logger.info("Registered toolListChangedSpec for client '{}'.", name);
143+
}
144+
});
145+
}
146+
147+
if (!CollectionUtils.isEmpty(syncResourceListChangedSpecifications)) {
148+
this.syncResourceListChangedSpecifications.forEach(resourceListChangedSpec -> {
149+
if (resourceListChangedSpec.clientId().equalsIgnoreCase(name)) {
150+
clientSpec.resourcesChangeConsumer(resourceListChangedSpec.resourceListChangeHandler());
151+
logger.info("Registered resourceListChangedSpec for client '{}'.", name);
152+
}
153+
});
154+
}
155+
156+
if (!CollectionUtils.isEmpty(syncPromptListChangedSpecifications)) {
157+
this.syncPromptListChangedSpecifications.forEach(promptListChangedSpec -> {
158+
if (promptListChangedSpec.clientId().equalsIgnoreCase(name)) {
159+
clientSpec.promptsChangeConsumer(promptListChangedSpec.promptListChangeHandler());
160+
logger.info("Registered promptListChangedSpec for client '{}'.", name);
86161
}
87162
});
88163
}

0 commit comments

Comments
 (0)