Skip to content

Commit 2401b7e

Browse files
committed
Find MCP Client annotations on @component beans
Signed-off-by: Daniel Garnier-Moiroux <[email protected]>
1 parent 1a11372 commit 2401b7e

File tree

5 files changed

+132
-35
lines changed

5 files changed

+132
-35
lines changed

auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webflux/src/test/java/org/springframework/ai/mcp/server/autoconfigure/StreamableMcpAnnotationsWithLLMIT.java

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,11 @@
3636
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
3737
import org.slf4j.Logger;
3838
import org.slf4j.LoggerFactory;
39-
import org.springaicommunity.mcp.annotation.McpElicitation;
4039
import org.springaicommunity.mcp.annotation.McpLogging;
4140
import org.springaicommunity.mcp.annotation.McpProgress;
42-
import org.springaicommunity.mcp.annotation.McpSampling;
4341
import org.springaicommunity.mcp.annotation.McpTool;
4442
import org.springaicommunity.mcp.annotation.McpToolParam;
4543
import org.springaicommunity.mcp.context.McpSyncRequestContext;
46-
import org.springaicommunity.mcp.context.StructuredElicitResult;
4744
import reactor.netty.DisposableServer;
4845
import reactor.netty.http.server.HttpServer;
4946

@@ -52,6 +49,7 @@
5249
import org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration;
5350
import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration;
5451
import org.springframework.ai.mcp.client.webflux.autoconfigure.StreamableHttpWebFluxTransportAutoConfiguration;
52+
import org.springframework.ai.mcp.server.autoconfigure.capabilities.McpHandlerService;
5553
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerAutoConfiguration;
5654
import org.springframework.ai.mcp.server.common.autoconfigure.ToolCallbackConverterAutoConfiguration;
5755
import org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerAnnotationScannerAutoConfiguration;
@@ -69,6 +67,7 @@
6967
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
7068
import org.springframework.context.ApplicationContext;
7169
import org.springframework.context.annotation.Bean;
70+
import org.springframework.context.annotation.ComponentScan;
7271
import org.springframework.http.server.reactive.HttpHandler;
7372
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
7473
import org.springframework.test.util.TestSocketUtils;
@@ -218,9 +217,6 @@ private static void stopHttpServer(DisposableServer server) {
218217
}
219218
}
220219

221-
record ElicitInput(String message) {
222-
}
223-
224220
public static class TestMcpServerConfiguration {
225221

226222
@Bean
@@ -242,7 +238,7 @@ public String weather(McpSyncRequestContext ctx, @McpToolParam String cityName)
242238
ctx.ping(); // call client ping
243239

244240
// call elicitation
245-
var elicitationResult = ctx.elicit(e -> e.message("Test message"), ElicitInput.class);
241+
var elicitationResult = ctx.elicit(e -> e.message("Test message"), McpHandlerService.ElicitInput.class);
246242

247243
ctx.progress(p -> p.progress(0.50).total(1.0).message("elicitation completed"));
248244

@@ -283,18 +279,16 @@ public static class TestContext {
283279

284280
}
285281

282+
// We also include scanned beans, because those are registered differently.
283+
@ComponentScan(basePackageClasses = McpHandlerService.class)
286284
public static class TestMcpClientHandlers {
287285

288286
private static final Logger logger = LoggerFactory.getLogger(TestMcpClientHandlers.class);
289287

290-
private final ChatClient client;
291-
292288
private TestMcpClientConfiguration.TestContext testContext;
293289

294-
public TestMcpClientHandlers(TestMcpClientConfiguration.TestContext testContext,
295-
ChatClient.Builder clientBuilder) {
290+
public TestMcpClientHandlers(TestMcpClientConfiguration.TestContext testContext) {
296291
this.testContext = testContext;
297-
this.client = clientBuilder.build();
298292
}
299293

300294
@McpProgress(clients = "server1")
@@ -311,28 +305,6 @@ public void loggingHandler(McpSchema.LoggingMessageNotification loggingMessage)
311305
logger.info("MCP LOGGING: [{}] {}", loggingMessage.level(), loggingMessage.data());
312306
}
313307

314-
@McpSampling(clients = "server1")
315-
public McpSchema.CreateMessageResult samplingHandler(McpSchema.CreateMessageRequest llmRequest) {
316-
logger.info("MCP SAMPLING: {}", llmRequest);
317-
318-
String userPrompt = ((McpSchema.TextContent) llmRequest.messages().get(0).content()).text();
319-
String modelHint = llmRequest.modelPreferences().hints().get(0).name();
320-
// In a real use-case, we would use the chat client to call the LLM again
321-
logger.info("MCP SAMPLING: simulating using chat client {}", this.client);
322-
323-
return McpSchema.CreateMessageResult.builder()
324-
.content(new McpSchema.TextContent("Response " + userPrompt + " with model hint " + modelHint))
325-
.build();
326-
}
327-
328-
@McpElicitation(clients = "server1")
329-
public StructuredElicitResult<ElicitInput> elicitationHandler(McpSchema.ElicitRequest request) {
330-
logger.info("MCP ELICITATION: {}", request);
331-
StreamableMcpAnnotationsWithLLMIT.ElicitInput elicitData = new StreamableMcpAnnotationsWithLLMIT.ElicitInput(
332-
request.message());
333-
return StructuredElicitResult.builder().structuredContent(elicitData).build();
334-
}
335-
336308
}
337309

338310
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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.server.autoconfigure.capabilities;
18+
19+
import io.modelcontextprotocol.spec.McpSchema;
20+
import org.slf4j.Logger;
21+
import org.slf4j.LoggerFactory;
22+
import org.springaicommunity.mcp.annotation.McpElicitation;
23+
import org.springaicommunity.mcp.annotation.McpSampling;
24+
import org.springaicommunity.mcp.context.StructuredElicitResult;
25+
26+
import org.springframework.ai.chat.client.ChatClient;
27+
import org.springframework.stereotype.Service;
28+
29+
@Service
30+
public class McpHandlerService {
31+
32+
private static final Logger logger = LoggerFactory.getLogger(McpHandlerService.class);
33+
34+
private final ChatClient client;
35+
36+
public McpHandlerService(ChatClient.Builder chatClientBuilder) {
37+
this.client = chatClientBuilder.build();
38+
}
39+
40+
@McpSampling(clients = "server1")
41+
public McpSchema.CreateMessageResult samplingHandler(McpSchema.CreateMessageRequest llmRequest) {
42+
logger.info("MCP SAMPLING: {}", llmRequest);
43+
44+
String userPrompt = ((McpSchema.TextContent) llmRequest.messages().get(0).content()).text();
45+
String modelHint = llmRequest.modelPreferences().hints().get(0).name();
46+
// In a real use-case, we would use the chat client to call the LLM again
47+
logger.info("MCP SAMPLING: simulating using chat client {}", this.client);
48+
49+
return McpSchema.CreateMessageResult.builder()
50+
.content(new McpSchema.TextContent("Response " + userPrompt + " with model hint " + modelHint))
51+
.build();
52+
}
53+
54+
@McpElicitation(clients = "server1")
55+
public StructuredElicitResult<ElicitInput> elicitationHandler(McpSchema.ElicitRequest request) {
56+
logger.info("MCP ELICITATION: {}", request);
57+
ElicitInput elicitData = new ElicitInput(request.message());
58+
return StructuredElicitResult.builder().structuredContent(elicitData).build();
59+
}
60+
61+
public record ElicitInput(String message) {
62+
}
63+
64+
}

mcp/mcp-annotations-spring/src/main/java/org/springframework/ai/mcp/annotation/spring/AbstractClientMcpHandlerRegistry.java

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
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+
117
package org.springframework.ai.mcp.annotation.spring;
218

319
import java.lang.annotation.Annotation;
@@ -20,6 +36,7 @@
2036
import org.springaicommunity.mcp.annotation.McpToolListChanged;
2137

2238
import org.springframework.beans.BeansException;
39+
import org.springframework.beans.factory.config.BeanDefinition;
2340
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
2441
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
2542
import org.springframework.core.annotation.AnnotationUtils;
@@ -28,6 +45,7 @@
2845
/**
2946
* Base class for sync and async ClientMcpHandlerRegistries. Not intended for public use.
3047
*
48+
* @author Daniel Garnier-Moiroux
3149
* @see ClientMcpAsyncHandlersRegistry
3250
* @see ClientMcpSyncHandlersRegistry
3351
*/
@@ -53,7 +71,7 @@ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory)
5371
Map<String, List<String>> samplingClientToAnnotatedBeans = new HashMap<>();
5472
for (var beanName : beanFactory.getBeanDefinitionNames()) {
5573
var definition = beanFactory.getBeanDefinition(beanName);
56-
var foundAnnotations = scan(definition.getResolvableType().toClass());
74+
var foundAnnotations = scan(getBeanClass(definition, beanFactory.getBeanClassLoader()));
5775
if (!foundAnnotations.isEmpty()) {
5876
this.allAnnotatedBeans.add(beanName);
5977
}
@@ -100,6 +118,23 @@ else if (foundAnnotation instanceof McpElicitation elicitation) {
100118
.collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().build()));
101119
}
102120

121+
private static Class<?> getBeanClass(BeanDefinition definition, ClassLoader beanClassLoader) {
122+
if (definition.getResolvableType().resolve() != null) {
123+
return definition.getResolvableType().resolve();
124+
}
125+
// @Component beans registered by component scanning do not have a resolvable type
126+
// We try to resolve them using the beanClassName (which might be null)
127+
if (beanClassLoader != null && definition.getBeanClassName() != null) {
128+
try {
129+
return Class.forName(definition.getBeanClassName(), false, beanClassLoader);
130+
}
131+
catch (ClassNotFoundException ignored) {
132+
133+
}
134+
}
135+
return null;
136+
}
137+
103138
protected List<Annotation> scan(Class<?> beanClass) {
104139
List<Annotation> foundAnnotations = new ArrayList<>();
105140

mcp/mcp-annotations-spring/src/test/java/org/springframework/ai/mcp/annotation/spring/ClientMcpAsyncHandlersRegistryTests.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,20 @@ void resourceListChanged() {
266266
new HandlersConfiguration.Call("handleResourceListChangedAgain", updatedResources));
267267
}
268268

269+
@Test
270+
void supportsNonResolvableTypes() {
271+
var registry = new ClientMcpSyncHandlersRegistry();
272+
var beanFactory = new DefaultListableBeanFactory();
273+
beanFactory.registerBeanDefinition("myConfig",
274+
BeanDefinitionBuilder
275+
.genericBeanDefinition(
276+
ClientMcpSyncHandlersRegistryTests.ClientCapabilitiesConfiguration.class.getName())
277+
.getBeanDefinition());
278+
registry.postProcessBeanFactory(beanFactory);
279+
280+
assertThat(registry.getCapabilities("client-1").elicitation()).isNotNull();
281+
}
282+
269283
@Test
270284
@Disabled
271285
void missingHandler() {

mcp/mcp-annotations-spring/src/test/java/org/springframework/ai/mcp/annotation/spring/ClientMcpSyncHandlersRegistryTests.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,18 @@ void resourceListChanged() {
264264
new HandlersConfiguration.Call("handleResourceListChangedAgain", updatedResources));
265265
}
266266

267+
@Test
268+
void supportsNonResolvableTypes() {
269+
var registry = new ClientMcpSyncHandlersRegistry();
270+
var beanFactory = new DefaultListableBeanFactory();
271+
beanFactory.registerBeanDefinition("myConfig",
272+
BeanDefinitionBuilder.genericBeanDefinition(ClientCapabilitiesConfiguration.class.getName())
273+
.getBeanDefinition());
274+
registry.postProcessBeanFactory(beanFactory);
275+
276+
assertThat(registry.getCapabilities("client-1").elicitation()).isNotNull();
277+
}
278+
267279
@Test
268280
@Disabled
269281
void missingHandler() {

0 commit comments

Comments
 (0)