Skip to content

Commit febf86c

Browse files
authored
Do not inject MCP ToolCallbackProviders into ToolCallbackResolver (#4751)
- MCP ToolCallbackProviders should not be "resolved" at startup time by the auto-configuration, ie we don't want to call #getToolCallback eagerly - 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 <git@garnier.wf>
1 parent 50db344 commit febf86c

File tree

4 files changed

+695
-4
lines changed

4 files changed

+695
-4
lines changed

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,23 @@
9191
<artifactId>junit-jupiter</artifactId>
9292
<scope>test</scope>
9393
</dependency>
94+
95+
<!-- For MCP sampling tests -->
96+
<dependency>
97+
<groupId>org.springframework.ai</groupId>
98+
<artifactId>spring-ai-autoconfigure-model-tool</artifactId>
99+
<version>${project.parent.version}</version>
100+
<scope>test</scope>
101+
</dependency>
102+
103+
<!-- For MCP sampling tests -->
104+
<dependency>
105+
<groupId>org.springframework.ai</groupId>
106+
<artifactId>spring-ai-autoconfigure-model-chat-client</artifactId>
107+
<version>${project.parent.version}</version>
108+
<scope>test</scope>
109+
</dependency>
110+
94111
</dependencies>
95112

96113
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
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.webflux.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.SyncMcpToolCallbackProvider;
31+
import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration;
32+
import org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration;
33+
import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration;
34+
import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientSpecificationFactoryAutoConfiguration;
35+
import org.springframework.ai.model.chat.client.autoconfigure.ChatClientAutoConfiguration;
36+
import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration;
37+
import org.springframework.ai.tool.ToolCallback;
38+
import org.springframework.ai.tool.ToolCallbackProvider;
39+
import org.springframework.ai.tool.definition.ToolDefinition;
40+
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
41+
import org.springframework.ai.util.json.schema.JsonSchemaGenerator;
42+
import org.springframework.boot.autoconfigure.AutoConfigurations;
43+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
44+
import org.springframework.context.annotation.Bean;
45+
import org.springframework.core.ParameterizedTypeReference;
46+
import org.springframework.core.ResolvableType;
47+
48+
import static org.assertj.core.api.Assertions.assertThat;
49+
import static org.mockito.Mockito.mock;
50+
import static org.mockito.Mockito.when;
51+
52+
/**
53+
* @author Daniel Garnier-Moiroux
54+
*/
55+
class McpToolsConfigurationTests {
56+
57+
/**
58+
* Test that MCP tools have handlers configured when they use a chat client. This
59+
* verifies that there is no cyclic dependency
60+
* {@code McpClient -> @McpHandling -> ChatClient -> McpClient}.
61+
*/
62+
@Test
63+
void mcpClientSupportsSampling() {
64+
//@formatter:off
65+
var clientApplicationContext = new ApplicationContextRunner()
66+
.withUserConfiguration(TestMcpClientHandlers.class)
67+
// Create a transport
68+
.withPropertyValues("spring.ai.mcp.client.streamable-http.connections.server1.url=http://localhost:0",
69+
"spring.ai.mcp.client.initialized=false")
70+
.withConfiguration(AutoConfigurations.of(
71+
// Transport
72+
StreamableHttpWebFluxTransportAutoConfiguration.class,
73+
// MCP clients
74+
McpToolCallbackAutoConfiguration.class,
75+
McpClientAutoConfiguration.class,
76+
McpClientAnnotationScannerAutoConfiguration.class,
77+
McpClientSpecificationFactoryAutoConfiguration.class,
78+
// Tool callbacks
79+
ToolCallingAutoConfiguration.class,
80+
// Chat client for sampling
81+
ChatClientAutoConfiguration.class,
82+
ChatModelAutoConfiguration.class
83+
));
84+
//@formatter:on
85+
clientApplicationContext.run(ctx -> {
86+
// If the MCP callback provider is picked un by the
87+
// ToolCallingAutoConfiguration,
88+
// #getToolCallbacks will be called during the init phase, and try to call the
89+
// MCP server
90+
// There is no MCP server in this test, so the context would not even start.
91+
String[] clients = ctx
92+
.getBeanNamesForType(ResolvableType.forType(new ParameterizedTypeReference<List<McpSyncClient>>() {
93+
}));
94+
assertThat(clients).hasSize(1);
95+
List<McpSyncClient> syncClients = (List<McpSyncClient>) ctx.getBean(clients[0]);
96+
assertThat(syncClients).hasSize(1)
97+
.first()
98+
.extracting(McpSyncClient::getClientCapabilities)
99+
.extracting(McpSchema.ClientCapabilities::sampling)
100+
.describedAs("Sampling")
101+
.isNotNull();
102+
});
103+
}
104+
105+
/**
106+
* Ensure that MCP-related {@link ToolCallbackProvider}s do not get their
107+
* {@code getToolCallbacks} method called on startup, and that, when possible, they
108+
* are not injected into the default {@link ToolCallbackResolver}.
109+
*/
110+
@Test
111+
void toolCallbacksRegistered() {
112+
var clientApplicationContext = new ApplicationContextRunner()
113+
.withUserConfiguration(TestToolCallbackConfiguration.class)
114+
.withConfiguration(AutoConfigurations.of(ToolCallingAutoConfiguration.class));
115+
116+
clientApplicationContext.run(ctx -> {
117+
// Observable behavior
118+
var resolver = ctx.getBean(ToolCallbackResolver.class);
119+
120+
// Resolves beans that are NOT mcp-related
121+
assertThat(resolver.resolve("toolCallbackProvider")).isNotNull();
122+
assertThat(resolver.resolve("customToolCallbackProvider")).isNotNull();
123+
124+
// MCP toolcallback providers are never added to the resolver
125+
126+
// Bean graph setup
127+
var injectedProviders = (List<ToolCallbackProvider>) ctx.getBean(
128+
"org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration.toolcallbackprovider.mcp-excluded");
129+
// Beans exposed as non-MCP
130+
var toolCallbackProvider = (ToolCallbackProvider) ctx.getBean("toolCallbackProvider");
131+
var customToolCallbackProvider = (ToolCallbackProvider) ctx.getBean("customToolCallbackProvider");
132+
// This is injected in the resolver bean, because it's exposed as a
133+
// ToolCallbackProvider, but it's not added to the resolver
134+
var genericMcpToolCallbackProvider = (ToolCallbackProvider) ctx.getBean("genericMcpToolCallbackProvider");
135+
136+
// beans exposed as MCP
137+
var mcpToolCallbackProvider = (ToolCallbackProvider) ctx.getBean("mcpToolCallbackProvider");
138+
var customMcpToolCallbackProvider = (ToolCallbackProvider) ctx.getBean("customMcpToolCallbackProvider");
139+
140+
assertThat(injectedProviders)
141+
.containsExactlyInAnyOrder(toolCallbackProvider, customToolCallbackProvider,
142+
genericMcpToolCallbackProvider)
143+
.doesNotContain(mcpToolCallbackProvider, customMcpToolCallbackProvider);
144+
145+
});
146+
}
147+
148+
static class TestMcpClientHandlers {
149+
150+
private static final Logger logger = LoggerFactory.getLogger(TestMcpClientHandlers.class);
151+
152+
private final ChatClient chatClient;
153+
154+
TestMcpClientHandlers(ChatClient.Builder clientBuilder) {
155+
this.chatClient = clientBuilder.build();
156+
}
157+
158+
@McpSampling(clients = "server1")
159+
McpSchema.CreateMessageResult samplingHandler(McpSchema.CreateMessageRequest llmRequest) {
160+
logger.info("MCP SAMPLING: {}", llmRequest);
161+
162+
String userPrompt = ((McpSchema.TextContent) llmRequest.messages().get(0).content()).text();
163+
String modelHint = llmRequest.modelPreferences().hints().get(0).name();
164+
// In a real use-case, we would use the chat client to call the LLM again
165+
logger.info("MCP SAMPLING: simulating using chat client {}", this.chatClient);
166+
167+
return McpSchema.CreateMessageResult.builder()
168+
.content(new McpSchema.TextContent("Response " + userPrompt + " with model hint " + modelHint))
169+
.build();
170+
}
171+
172+
}
173+
174+
static class ChatModelAutoConfiguration {
175+
176+
/**
177+
* This is typically provided by a model-specific autoconfig, such as
178+
* {@code AnthropicChatAutoConfiguration}. We do not need a full LLM in this test,
179+
* so we mock out the chat model.
180+
*/
181+
@Bean
182+
ChatModel chatModel() {
183+
return mock(ChatModel.class);
184+
}
185+
186+
}
187+
188+
static class TestToolCallbackConfiguration {
189+
190+
@Bean
191+
ToolCallbackProvider toolCallbackProvider() {
192+
var tcp = mock(ToolCallbackProvider.class);
193+
when(tcp.getToolCallbacks()).thenReturn(toolCallback("toolCallbackProvider"));
194+
return tcp;
195+
}
196+
197+
// This bean depends on the resolver, to ensure there are no cyclic dependencies
198+
@Bean
199+
SyncMcpToolCallbackProvider mcpToolCallbackProvider(ToolCallbackResolver resolver) {
200+
var tcp = mock(SyncMcpToolCallbackProvider.class);
201+
when(tcp.getToolCallbacks())
202+
.thenThrow(new RuntimeException("mcpToolCallbackProvider#getToolCallbacks should not be called"));
203+
return tcp;
204+
}
205+
206+
@Bean
207+
CustomToolCallbackProvider customToolCallbackProvider() {
208+
return new CustomToolCallbackProvider("customToolCallbackProvider");
209+
}
210+
211+
// This bean depends on the resolver, to ensure there are no cyclic dependencies
212+
@Bean
213+
CustomMcpToolCallbackProvider customMcpToolCallbackProvider(ToolCallbackResolver resolver) {
214+
return new CustomMcpToolCallbackProvider();
215+
}
216+
217+
// This will be added to the resolver, because the visible type of the bean
218+
// is ToolCallbackProvider ; we would need to actually instantiate the bean
219+
// to find out that it is MCP-related
220+
@Bean
221+
ToolCallbackProvider genericMcpToolCallbackProvider() {
222+
return new CustomMcpToolCallbackProvider();
223+
}
224+
225+
static ToolCallback[] toolCallback(String name) {
226+
return new ToolCallback[] { new ToolCallback() {
227+
@Override
228+
public ToolDefinition getToolDefinition() {
229+
return ToolDefinition.builder()
230+
.name(name)
231+
.inputSchema(JsonSchemaGenerator.generateForType(String.class))
232+
.build();
233+
}
234+
235+
@Override
236+
public String call(String toolInput) {
237+
return "~~ not implemented ~~";
238+
}
239+
} };
240+
}
241+
242+
static class CustomToolCallbackProvider implements ToolCallbackProvider {
243+
244+
private final String name;
245+
246+
CustomToolCallbackProvider(String name) {
247+
this.name = name;
248+
}
249+
250+
@Override
251+
public ToolCallback[] getToolCallbacks() {
252+
return toolCallback(this.name);
253+
}
254+
255+
}
256+
257+
static class CustomMcpToolCallbackProvider extends SyncMcpToolCallbackProvider {
258+
259+
@Override
260+
public ToolCallback[] getToolCallbacks() {
261+
throw new RuntimeException("CustomMcpToolCallbackProvider#getToolCallbacks should not be called");
262+
}
263+
264+
}
265+
266+
}
267+
268+
}

0 commit comments

Comments
 (0)