Skip to content

Commit 8245542

Browse files
committed
Introduce LazilyResolvedToolCallbackProvider
- Some ToolCallbackProviders should not be "resolved" at startup time by the auto-configuration, ie we don't want to call #getToolCallback eagerly. This is useful for MCP clients, where we don't want to call #listTools on startup. - We also want to break the following dependency cycle: - ChatClient -> ToolCallingManager -> ToolCallbackResolver -> ToolCallbackProvider (incl. SyncMcpToolCallbackProvider) -> McpSyncClient -> ClientMcpAnnotatedBeans -> ChatClient (when there is Sampling) - This PR ensures that the ToolCallbackResolver does not depend on SyncMcpToolCallbackProvider, thus breaking the cycle. - MCP callback providers can still be passed to the chat client, but only at runtime, not during the configuration phase. Signed-off-by: Daniel Garnier-Moiroux <[email protected]>
1 parent b452e89 commit 8245542

File tree

7 files changed

+583
-7
lines changed

7 files changed

+583
-7
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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;
18+
19+
import java.util.List;
20+
21+
import io.modelcontextprotocol.client.McpSyncClient;
22+
import io.modelcontextprotocol.spec.McpSchema;
23+
import org.junit.jupiter.api.Test;
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
26+
import org.springaicommunity.mcp.annotation.McpSampling;
27+
28+
import org.springframework.ai.chat.client.ChatClient;
29+
import org.springframework.ai.chat.model.ChatModel;
30+
import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration;
31+
import org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration;
32+
import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration;
33+
import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientSpecificationFactoryAutoConfiguration;
34+
import org.springframework.ai.mcp.client.webflux.autoconfigure.StreamableHttpWebFluxTransportAutoConfiguration;
35+
import org.springframework.ai.model.chat.client.autoconfigure.ChatClientAutoConfiguration;
36+
import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration;
37+
import org.springframework.boot.autoconfigure.AutoConfigurations;
38+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
39+
import org.springframework.context.annotation.Bean;
40+
import org.springframework.core.ParameterizedTypeReference;
41+
import org.springframework.core.ResolvableType;
42+
43+
import static org.assertj.core.api.Assertions.assertThat;
44+
import static org.mockito.Mockito.mock;
45+
46+
/**
47+
* Test that MCP tools have handlers configured when they use a chat client. This verifies
48+
* that there is no cyclic dependency
49+
* {@code McpClient -> @McpHandling -> ChatClient -> McpClient}.
50+
*
51+
* @author Daniel Garnier-Moiroux
52+
*/
53+
class McpToolsConfigurationTests {
54+
55+
private static final Logger log = LoggerFactory.getLogger(McpToolsConfigurationTests.class);
56+
57+
private final ApplicationContextRunner clientApplicationContext = new ApplicationContextRunner()
58+
.withUserConfiguration(TestMcpClientHandlers.class)
59+
// Create a transport
60+
.withPropertyValues("spring.ai.mcp.client.streamable-http.connections.server1.url=http://localhost:0",
61+
"spring.ai.mcp.client.initialized=false")
62+
.withConfiguration(AutoConfigurations.of(McpToolCallbackAutoConfiguration.class,
63+
McpClientAutoConfiguration.class, StreamableHttpWebFluxTransportAutoConfiguration.class,
64+
McpClientAnnotationScannerAutoConfiguration.class, McpClientSpecificationFactoryAutoConfiguration.class,
65+
ChatModelAutoConfiguration.class, ToolCallingAutoConfiguration.class,
66+
ChatClientAutoConfiguration.class));
67+
68+
@Test
69+
void mcpClientSupportsSampling() {
70+
// 1.
71+
this.clientApplicationContext.run(ctx -> {
72+
String[] clients = ctx
73+
.getBeanNamesForType(ResolvableType.forType(new ParameterizedTypeReference<List<McpSyncClient>>() {
74+
}));
75+
assertThat(clients).hasSize(1);
76+
List<McpSyncClient> syncClients = (List<McpSyncClient>) ctx.getBean(clients[0]);
77+
assertThat(syncClients).hasSize(1)
78+
.first()
79+
.extracting(McpSyncClient::getClientCapabilities)
80+
.extracting(McpSchema.ClientCapabilities::sampling)
81+
.describedAs("Sampling")
82+
.isNotNull();
83+
});
84+
}
85+
86+
static class TestMcpClientHandlers {
87+
88+
private static final Logger logger = LoggerFactory
89+
.getLogger(StreamableMcpAnnotationsWithLLMIT.TestMcpClientHandlers.class);
90+
91+
private final ChatClient chatClient;
92+
93+
TestMcpClientHandlers(ChatClient.Builder clientBuilder) {
94+
this.chatClient = clientBuilder.build();
95+
}
96+
97+
@McpSampling(clients = "server1")
98+
McpSchema.CreateMessageResult samplingHandler(McpSchema.CreateMessageRequest llmRequest) {
99+
logger.info("MCP SAMPLING: {}", llmRequest);
100+
101+
String userPrompt = ((McpSchema.TextContent) llmRequest.messages().get(0).content()).text();
102+
String modelHint = llmRequest.modelPreferences().hints().get(0).name();
103+
// In a real use-case, we would use the chat client to call the LLM again
104+
logger.info("MCP SAMPLING: simulating using chat client {}", this.chatClient);
105+
106+
return McpSchema.CreateMessageResult.builder()
107+
.content(new McpSchema.TextContent("Response " + userPrompt + " with model hint " + modelHint))
108+
.build();
109+
}
110+
111+
}
112+
113+
static class ChatModelAutoConfiguration {
114+
115+
@Bean
116+
ChatModel chatModel() {
117+
return mock(ChatModel.class);
118+
}
119+
120+
}
121+
122+
// Theory: server does not suffer from cyclical dependencies
123+
124+
}

0 commit comments

Comments
 (0)