Skip to content

Commit 6944cf5

Browse files
committed
feat: Add annotation-based configuration support for MCP clients and servers
- Introduce module for declarative MCP configuration - Create new mcp-annotations-spring module - Add annotation scanners to auto-discover MCP annotated beans and methods - Support MCP annotations: @mcptool, @McpResource, @McpPrompt, @McpComplete for servers - Support MCP annotations: @McpLogging, @McpSampling, @McpElicitation, @McpProgress for clients - Implement customizers to automatically register annotated specifications - Introduce McpAsyncAnnotationCustomizer and McpSyncAnnotationCustomizer for annotation-driven client configuration - Add Spring-aware annotation providers for both sync and async MCP operations - Include comprehensive integration tests for annotation-based MCP configuration - Update all MCP starter dependencies to include the new annotations module - Update BOM and parent POM to include mcp-annotations dependency version management - Include comprehensive integration test StreamableMcpAnnotationsIT demonstrating annotation usage This enhancement simplifies MCP setup by allowing developers to use annotations instead of manual bean configuration, improving developer experience and reducing boilerplate code. Signed-off-by: Christian Tzolov <[email protected]>
1 parent a5685a1 commit 6944cf5

File tree

34 files changed

+2839
-12
lines changed

34 files changed

+2839
-12
lines changed

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/pom.xml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@
3535
<optional>true</optional>
3636
</dependency>
3737

38-
<!-- <dependency>
39-
<groupId>io.modelcontextprotocol.sdk</groupId>
40-
<artifactId>mcp-spring-webflux</artifactId>
38+
<dependency>
39+
<groupId>org.springframework.ai</groupId>
40+
<artifactId>spring-ai-mcp-annotations</artifactId>
41+
<version>${project.parent.version}</version>
4142
<optional>true</optional>
42-
</dependency> -->
43+
</dependency>
4344

4445
<dependency>
4546
<groupId>org.springframework.boot</groupId>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.mcp.client.common.autoconfigure;
18+
19+
import java.util.List;
20+
21+
import org.springaicommunity.mcp.method.elicitation.AsyncElicitationSpecification;
22+
import org.springaicommunity.mcp.method.logging.AsyncLoggingSpecification;
23+
import org.springaicommunity.mcp.method.progress.AsyncProgressSpecification;
24+
import org.springaicommunity.mcp.method.sampling.AsyncSamplingSpecification;
25+
import org.springframework.ai.mcp.customizer.McpAsyncClientCustomizer;
26+
import org.springframework.util.CollectionUtils;
27+
import org.springframework.util.StringUtils;
28+
29+
import io.modelcontextprotocol.client.McpClient.AsyncSpec;
30+
31+
/**
32+
* @author Christian Tzolov
33+
*/
34+
public class McpAsyncAnnotationCustomizer implements McpAsyncClientCustomizer {
35+
36+
private final List<AsyncSamplingSpecification> asyncSamplingSpecifications;
37+
38+
private final List<AsyncLoggingSpecification> asyncLoggingSpecifications;
39+
40+
private final List<AsyncElicitationSpecification> asyncElicitationSpecifications;
41+
42+
private final List<AsyncProgressSpecification> asyncProgressSpecifications;
43+
44+
public McpAsyncAnnotationCustomizer(List<AsyncSamplingSpecification> asyncSamplingSpecifications,
45+
List<AsyncLoggingSpecification> asyncLoggingSpecifications,
46+
List<AsyncElicitationSpecification> asyncElicitationSpecifications,
47+
List<AsyncProgressSpecification> asyncProgressSpecifications) {
48+
49+
this.asyncSamplingSpecifications = asyncSamplingSpecifications;
50+
this.asyncLoggingSpecifications = asyncLoggingSpecifications;
51+
this.asyncElicitationSpecifications = asyncElicitationSpecifications;
52+
this.asyncProgressSpecifications = asyncProgressSpecifications;
53+
}
54+
55+
@Override
56+
public void customize(String name, AsyncSpec clientSpec) {
57+
58+
if (!CollectionUtils.isEmpty(asyncElicitationSpecifications)) {
59+
this.asyncElicitationSpecifications.forEach(elicitationSpec -> {
60+
if (!StringUtils.hasText(elicitationSpec.clientId()) || elicitationSpec.clientId().equals(name)) {
61+
clientSpec.elicitation(elicitationSpec.elicitationHandler());
62+
}
63+
});
64+
}
65+
66+
if (!CollectionUtils.isEmpty(asyncSamplingSpecifications)) {
67+
this.asyncSamplingSpecifications.forEach(samplingSpec -> {
68+
if (!StringUtils.hasText(samplingSpec.clientId()) || samplingSpec.clientId().equals(name)) {
69+
clientSpec.sampling(samplingSpec.samplingHandler());
70+
}
71+
});
72+
}
73+
74+
if (!CollectionUtils.isEmpty(asyncLoggingSpecifications)) {
75+
this.asyncLoggingSpecifications.forEach(loggingSpec -> {
76+
if (!StringUtils.hasText(loggingSpec.clientId()) || loggingSpec.clientId().equals(name)) {
77+
clientSpec.loggingConsumer(loggingSpec.loggingHandler());
78+
}
79+
});
80+
}
81+
82+
if (!CollectionUtils.isEmpty(asyncProgressSpecifications)) {
83+
this.asyncProgressSpecifications.forEach(progressSpec -> {
84+
if (!StringUtils.hasText(progressSpec.clientId()) || progressSpec.clientId().equals(name)) {
85+
clientSpec.progressConsumer(progressSpec.progressHandler());
86+
}
87+
});
88+
}
89+
}
90+
91+
}

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

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

27+
import org.springaicommunity.mcp.method.elicitation.AsyncElicitationSpecification;
28+
import org.springaicommunity.mcp.method.elicitation.SyncElicitationSpecification;
29+
import org.springaicommunity.mcp.method.logging.AsyncLoggingSpecification;
30+
import org.springaicommunity.mcp.method.logging.SyncLoggingSpecification;
31+
import org.springaicommunity.mcp.method.progress.AsyncProgressSpecification;
32+
import org.springaicommunity.mcp.method.progress.SyncProgressSpecification;
33+
import org.springaicommunity.mcp.method.sampling.AsyncSamplingSpecification;
34+
import org.springaicommunity.mcp.method.sampling.SyncSamplingSpecification;
2735
import org.springframework.ai.mcp.client.common.autoconfigure.configurer.McpAsyncClientConfigurer;
2836
import org.springframework.ai.mcp.client.common.autoconfigure.configurer.McpSyncClientConfigurer;
2937
import org.springframework.ai.mcp.client.common.autoconfigure.properties.McpClientCommonProperties;
@@ -208,6 +216,15 @@ McpSyncClientConfigurer mcpSyncClientConfigurer(ObjectProvider<McpSyncClientCust
208216
return new McpSyncClientConfigurer(customizerProvider.orderedStream().toList());
209217
}
210218

219+
@Bean
220+
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC",
221+
matchIfMissing = true)
222+
public McpSyncClientCustomizer mcpAnnotationMcpSyncClientCustomizer(List<SyncLoggingSpecification> loggingSpecs,
223+
List<SyncSamplingSpecification> samplingSpecs, List<SyncElicitationSpecification> elicitationSpecs,
224+
List<SyncProgressSpecification> progressSpecs) {
225+
return new McpSyncAnnotationCustomizer(samplingSpecs, loggingSpecs, elicitationSpecs, progressSpecs);
226+
}
227+
211228
// Async client configuration
212229

213230
@Bean
@@ -259,6 +276,14 @@ McpAsyncClientConfigurer mcpAsyncClientConfigurer(ObjectProvider<McpAsyncClientC
259276
return new McpAsyncClientConfigurer(customizerProvider.orderedStream().toList());
260277
}
261278

279+
@Bean
280+
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC")
281+
public McpAsyncClientCustomizer mcpAnnotationMcpAsyncClientCustomizer(List<AsyncLoggingSpecification> loggingSpecs,
282+
List<AsyncSamplingSpecification> samplingSpecs, List<AsyncElicitationSpecification> elicitationSpecs,
283+
List<AsyncProgressSpecification> progressSpecs) {
284+
return new McpAsyncAnnotationCustomizer(samplingSpecs, loggingSpecs, elicitationSpecs, progressSpecs);
285+
}
286+
262287
/**
263288
* Record class that implements {@link AutoCloseable} to ensure proper cleanup of MCP
264289
* clients.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.mcp.client.common.autoconfigure;
18+
19+
import java.util.List;
20+
21+
import org.springaicommunity.mcp.method.elicitation.SyncElicitationSpecification;
22+
import org.springaicommunity.mcp.method.logging.SyncLoggingSpecification;
23+
import org.springaicommunity.mcp.method.progress.SyncProgressSpecification;
24+
import org.springaicommunity.mcp.method.sampling.SyncSamplingSpecification;
25+
import org.springframework.ai.mcp.customizer.McpSyncClientCustomizer;
26+
import org.springframework.util.CollectionUtils;
27+
import org.springframework.util.StringUtils;
28+
29+
import io.modelcontextprotocol.client.McpClient.SyncSpec;
30+
31+
/**
32+
* @author Christian Tzolov
33+
*/
34+
public class McpSyncAnnotationCustomizer implements McpSyncClientCustomizer {
35+
36+
private final List<SyncSamplingSpecification> syncSamplingSpecifications;
37+
38+
private final List<SyncLoggingSpecification> syncLoggingSpecifications;
39+
40+
private final List<SyncElicitationSpecification> syncElicitationSpecifications;
41+
42+
private final List<SyncProgressSpecification> syncProgressSpecifications;
43+
44+
public McpSyncAnnotationCustomizer(List<SyncSamplingSpecification> syncSamplingSpecifications,
45+
List<SyncLoggingSpecification> syncLoggingSpecifications,
46+
List<SyncElicitationSpecification> syncElicitationSpecifications,
47+
List<SyncProgressSpecification> syncProgressSpecifications) {
48+
49+
this.syncSamplingSpecifications = syncSamplingSpecifications;
50+
this.syncLoggingSpecifications = syncLoggingSpecifications;
51+
this.syncElicitationSpecifications = syncElicitationSpecifications;
52+
this.syncProgressSpecifications = syncProgressSpecifications;
53+
}
54+
55+
@Override
56+
public void customize(String name, SyncSpec clientSpec) {
57+
58+
if (!CollectionUtils.isEmpty(syncElicitationSpecifications)) {
59+
this.syncElicitationSpecifications.forEach(elicitationSpec -> {
60+
if (!StringUtils.hasText(elicitationSpec.clientId()) || elicitationSpec.clientId().equals(name)) {
61+
clientSpec.elicitation(elicitationSpec.elicitationHandler());
62+
}
63+
});
64+
}
65+
66+
if (!CollectionUtils.isEmpty(syncSamplingSpecifications)) {
67+
this.syncSamplingSpecifications.forEach(samplingSpec -> {
68+
if (!StringUtils.hasText(samplingSpec.clientId()) || samplingSpec.clientId().equals(name)) {
69+
clientSpec.sampling(samplingSpec.samplingHandler());
70+
}
71+
});
72+
}
73+
74+
if (!CollectionUtils.isEmpty(syncLoggingSpecifications)) {
75+
this.syncLoggingSpecifications.forEach(loggingSpec -> {
76+
if (!StringUtils.hasText(loggingSpec.clientId()) || loggingSpec.clientId().equals(name)) {
77+
clientSpec.loggingConsumer(loggingSpec.loggingHandler());
78+
}
79+
});
80+
}
81+
82+
if (!CollectionUtils.isEmpty(syncProgressSpecifications)) {
83+
this.syncProgressSpecifications.forEach(progressSpec -> {
84+
if (!StringUtils.hasText(progressSpec.clientId()) || progressSpec.clientId().equals(name)) {
85+
clientSpec.progressConsumer(progressSpec.progressHandler());
86+
}
87+
});
88+
}
89+
}
90+
91+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
18+
19+
import java.lang.annotation.Annotation;
20+
import java.util.Set;
21+
22+
import org.springaicommunity.mcp.annotation.McpElicitation;
23+
import org.springaicommunity.mcp.annotation.McpLogging;
24+
import org.springaicommunity.mcp.annotation.McpProgress;
25+
import org.springaicommunity.mcp.annotation.McpSampling;
26+
import org.springframework.ai.mcp.annotation.spring.scan.AbstractAnnotatedBeanRegistry;
27+
import org.springframework.ai.mcp.annotation.spring.scan.AbstractAnnotatedMethodBeanPostProcessor;
28+
import org.springframework.boot.autoconfigure.AutoConfiguration;
29+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
30+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
31+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
32+
import org.springframework.context.annotation.Bean;
33+
34+
/**
35+
* @author Christian Tzolov
36+
*/
37+
@AutoConfiguration
38+
@ConditionalOnProperty(prefix = ClientAnnotationScannerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
39+
matchIfMissing = true)
40+
@EnableConfigurationProperties(ClientAnnotationScannerProperties.class)
41+
public class ClientAnnotationScannerAutoConfiguration {
42+
43+
private static final Set<Class<? extends Annotation>> CLIENT_MCP_ANNOTATIONS = Set.of(McpLogging.class,
44+
McpSampling.class, McpElicitation.class, McpProgress.class);
45+
46+
public static class ClientAnnotatedBeanRegistry extends AbstractAnnotatedBeanRegistry {
47+
48+
}
49+
50+
public static class ClientAnnotatedMethodBeanPostProcessor extends AbstractAnnotatedMethodBeanPostProcessor {
51+
52+
public ClientAnnotatedMethodBeanPostProcessor(ClientAnnotatedBeanRegistry registry,
53+
Set<Class<? extends Annotation>> targetAnnotations, Set<String> includePackages,
54+
Set<String> excludePackages) {
55+
super(registry, targetAnnotations, includePackages, excludePackages);
56+
}
57+
58+
}
59+
60+
@Bean
61+
@ConditionalOnMissingBean
62+
public ClientAnnotatedBeanRegistry clientAnnotatedBeanRegistry() {
63+
return new ClientAnnotatedBeanRegistry();
64+
}
65+
66+
@Bean
67+
@ConditionalOnMissingBean
68+
public ClientAnnotatedMethodBeanPostProcessor clientAnnotatedMethodBeanPostProcessor(
69+
ClientAnnotatedBeanRegistry registry, ClientAnnotationScannerProperties properties) {
70+
return new ClientAnnotatedMethodBeanPostProcessor(registry, CLIENT_MCP_ANNOTATIONS,
71+
properties.getIncludePackages(), properties.getExcludePackages());
72+
}
73+
74+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright 2025-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ai.mcp.client.common.autoconfigure.annotations;
18+
19+
import java.util.HashSet;
20+
import java.util.Set;
21+
22+
import org.springframework.boot.context.properties.ConfigurationProperties;
23+
24+
/**
25+
* @author Christian Tzolov
26+
*/
27+
@ConfigurationProperties(prefix = ClientAnnotationScannerProperties.CONFIG_PREFIX)
28+
public class ClientAnnotationScannerProperties {
29+
30+
public static final String CONFIG_PREFIX = "spring.ai.mcp.client.annotation-scanner";
31+
32+
private boolean enabled = true;
33+
34+
private Set<String> includePackages = new HashSet<>();
35+
36+
private Set<String> excludePackages = new HashSet<>();
37+
38+
public boolean isEnabled() {
39+
return enabled;
40+
}
41+
42+
public Set<String> getIncludePackages() {
43+
return includePackages;
44+
}
45+
46+
public Set<String> getExcludePackages() {
47+
return excludePackages;
48+
}
49+
50+
public void setEnabled(boolean enabled) {
51+
this.enabled = enabled;
52+
}
53+
54+
public void setIncludePackages(Set<String> includePackages) {
55+
this.includePackages = includePackages;
56+
}
57+
58+
public void setExcludePackages(Set<String> excludePackages) {
59+
this.excludePackages = excludePackages;
60+
}
61+
62+
}

0 commit comments

Comments
 (0)