Skip to content

Commit 8576ebf

Browse files
tzolovYunKuiLu
andcommitted
refactor(mcp): Add ToolContext to MCP metadata conversion
- Introduce ToolContextToMcpMetaConverter interface for converting Spring AI ToolContext to MCP call metadata - Refactor McpToolCallbackAutoConfiguration to use ObjectProvider for flexible dependency injection - Remove automatic McpToolNamePrefixGenerator bean creation in favor of ObjectProvider-based resolution - Add builder pattern for AsyncMcpToolCallback and SyncMcpToolCallback with comprehensive configuration options - Add builder pattern for AsyncMcpToolCallbackProvider and SyncMcpToolCallbackProvider - Enhance tool callback call methods to support ToolContext parameter with metadata conversion - Update MCP tool callbacks to use new CallToolRequest.builder() API - Improve error handling and logging in tool callback implementations - Add comprehensive coverage for new builder functionality - Update documentation with ToolContextToMcpMetaConverter usage examples - Maintain backward compatibility through deprecation of old constructors Breaking changes: - McpToolCallbackAutoConfiguration no longer automatically creates McpToolNamePrefixGenerator bean - Tool callback constructors are now private, use builder() method instead Resolves: spring-projects#3505 Resolves: spring-projects#2868 Resolves: spring-projects#2784 Resolves: spring-projects#2620 Replaces: spring-projects#3831 Signed-off-by: Christian Tzolov <[email protected]> Co-authored-by: YunKui Lu <[email protected]>
1 parent 3a1fe1f commit 8576ebf

File tree

20 files changed

+2610
-470
lines changed

20 files changed

+2610
-470
lines changed

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

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
import org.springframework.ai.mcp.McpToolFilter;
2626
import org.springframework.ai.mcp.McpToolNamePrefixGenerator;
2727
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
28+
import org.springframework.ai.mcp.ToolContextToMcpMetaConverter;
2829
import org.springframework.ai.mcp.client.common.autoconfigure.properties.McpClientCommonProperties;
2930
import org.springframework.beans.factory.ObjectProvider;
3031
import org.springframework.boot.autoconfigure.AutoConfiguration;
3132
import org.springframework.boot.autoconfigure.condition.AllNestedConditions;
32-
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
3333
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
3434
import org.springframework.boot.context.properties.EnableConfigurationProperties;
3535
import org.springframework.context.annotation.Bean;
@@ -42,23 +42,6 @@
4242
@Conditional(McpToolCallbackAutoConfiguration.McpToolCallbackAutoConfigurationCondition.class)
4343
public class McpToolCallbackAutoConfiguration {
4444

45-
/**
46-
* Provides a default {@link McpToolNamePrefixGenerator} bean if none is already
47-
* defined.
48-
* <p>
49-
* This generator is used to create uniquely prefixed tool names based on the MCP
50-
* connection information, helping to avoid name collisions when integrating tools
51-
* from multiple MCP servers.
52-
*
53-
* Register the McpToolNamePrefixGenerator.noPrefix() bean to disable the prefixing.
54-
* @return the default McpToolNamePrefixGenerator
55-
*/
56-
@Bean
57-
@ConditionalOnMissingBean
58-
public McpToolNamePrefixGenerator mcpToolNamePrefixGenerator() {
59-
return McpToolNamePrefixGenerator.defaultGenerator();
60-
}
61-
6245
/**
6346
* Creates tool callbacks for all configured MCP clients.
6447
*
@@ -75,21 +58,35 @@ public McpToolNamePrefixGenerator mcpToolNamePrefixGenerator() {
7558
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "SYNC",
7659
matchIfMissing = true)
7760
public SyncMcpToolCallbackProvider mcpToolCallbacks(ObjectProvider<McpToolFilter> syncClientsToolFilter,
78-
ObjectProvider<List<McpSyncClient>> syncMcpClients, McpToolNamePrefixGenerator mcpToolNamePrefixGenerator) {
61+
ObjectProvider<List<McpSyncClient>> syncMcpClients,
62+
ObjectProvider<McpToolNamePrefixGenerator> mcpToolNamePrefixGenerator,
63+
ObjectProvider<ToolContextToMcpMetaConverter> toolContextToMcpMetaConverter) {
7964
List<McpSyncClient> mcpClients = syncMcpClients.stream().flatMap(List::stream).toList();
80-
return new SyncMcpToolCallbackProvider(syncClientsToolFilter.getIfUnique((() -> (McpSyncClient, tool) -> true)),
81-
mcpToolNamePrefixGenerator, mcpClients);
65+
return SyncMcpToolCallbackProvider.builder()
66+
.mcpClients(mcpClients)
67+
.toolFilter(syncClientsToolFilter.getIfUnique((() -> (McpSyncClient, tool) -> true)))
68+
.toolNamePrefixGenerator(
69+
mcpToolNamePrefixGenerator.getIfUnique(() -> McpToolNamePrefixGenerator.defaultGenerator()))
70+
.toolContextToMcpMetaConverter(
71+
toolContextToMcpMetaConverter.getIfUnique(() -> ToolContextToMcpMetaConverter.defaultConverter()))
72+
.build();
8273
}
8374

8475
@Bean
8576
@ConditionalOnProperty(prefix = McpClientCommonProperties.CONFIG_PREFIX, name = "type", havingValue = "ASYNC")
8677
public AsyncMcpToolCallbackProvider mcpAsyncToolCallbacks(ObjectProvider<McpToolFilter> asyncClientsToolFilter,
8778
ObjectProvider<List<McpAsyncClient>> mcpClientsProvider,
88-
McpToolNamePrefixGenerator toolNamePrefixGenerator) {
79+
ObjectProvider<McpToolNamePrefixGenerator> toolNamePrefixGenerator,
80+
ObjectProvider<ToolContextToMcpMetaConverter> toolContextToMcpMetaConverter) { // TODO
8981
List<McpAsyncClient> mcpClients = mcpClientsProvider.stream().flatMap(List::stream).toList();
90-
return new AsyncMcpToolCallbackProvider(
91-
asyncClientsToolFilter.getIfUnique(() -> (McpAsyncClient, tool) -> true), toolNamePrefixGenerator,
92-
mcpClients);
82+
return AsyncMcpToolCallbackProvider.builder()
83+
.toolFilter(asyncClientsToolFilter.getIfUnique(() -> (McpAsyncClient, tool) -> true))
84+
.toolNamePrefixGenerator(
85+
toolNamePrefixGenerator.getIfUnique(() -> McpToolNamePrefixGenerator.defaultGenerator()))
86+
.toolContextToMcpMetaConverter(
87+
toolContextToMcpMetaConverter.getIfUnique(() -> ToolContextToMcpMetaConverter.defaultConverter()))
88+
.mcpClients(mcpClients)
89+
.build();
9390
}
9491

9592
public static class McpToolCallbackAutoConfigurationCondition extends AllNestedConditions {

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/McpToolCallbackAutoConfigurationConditionTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ void verifySyncToolCallbackFilterConfiguration() {
115115
}
116116

117117
@Test
118-
void verifyASyncToolCallbackFilterConfiguration() {
118+
void verifyAsyncToolCallbackFilterConfiguration() {
119119
this.contextRunner
120120
.withUserConfiguration(McpToolCallbackAutoConfiguration.class, McpClientFilterConfiguration.class)
121121
.withPropertyValues("spring.ai.mcp.client.type=ASYNC")

auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/test/java/org/springframework/ai/mcp/client/common/autoconfigure/McpToolCallbackAutoConfigurationTests.java

Lines changed: 188 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,29 @@
1616

1717
package org.springframework.ai.mcp.client.common.autoconfigure;
1818

19+
import java.util.List;
20+
import java.util.Map;
21+
22+
import io.modelcontextprotocol.client.McpAsyncClient;
23+
import io.modelcontextprotocol.client.McpSyncClient;
24+
import io.modelcontextprotocol.spec.McpSchema;
1925
import io.modelcontextprotocol.spec.McpSchema.Tool;
2026
import org.junit.jupiter.api.Test;
2127

28+
import org.springframework.ai.chat.model.ToolContext;
2229
import org.springframework.ai.mcp.AsyncMcpToolCallbackProvider;
2330
import org.springframework.ai.mcp.McpConnectionInfo;
31+
import org.springframework.ai.mcp.McpToolFilter;
2432
import org.springframework.ai.mcp.McpToolNamePrefixGenerator;
2533
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
34+
import org.springframework.ai.mcp.ToolContextToMcpMetaConverter;
2635
import org.springframework.boot.autoconfigure.AutoConfigurations;
2736
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
2837
import org.springframework.context.annotation.Bean;
2938
import org.springframework.context.annotation.Configuration;
3039

3140
import static org.assertj.core.api.Assertions.assertThat;
41+
import static org.mockito.Mockito.mock;
3242

3343
public class McpToolCallbackAutoConfigurationTests {
3444

@@ -93,20 +103,26 @@ void enabledMcpToolCallbackAutoConfiguration() {
93103
}
94104

95105
@Test
96-
void defaultMcpToolNamePrefixGeneratorIsCreated() {
97-
// Test with SYNC mode (default)
98-
this.applicationContext.run(context -> {
99-
assertThat(context).hasBean("mcpToolNamePrefixGenerator");
100-
McpToolNamePrefixGenerator generator = context.getBean(McpToolNamePrefixGenerator.class);
101-
assertThat(generator).isNotNull();
106+
void disabledMcpToolCallbackAutoConfiguration() {
107+
// Test when MCP client is disabled
108+
this.applicationContext.withPropertyValues("spring.ai.mcp.client.enabled=false").run(context -> {
109+
assertThat(context).doesNotHaveBean("mcpToolCallbacks");
110+
assertThat(context).doesNotHaveBean("mcpAsyncToolCallbacks");
102111
});
103112

104-
// Test with ASYNC mode
105-
this.applicationContext.withPropertyValues("spring.ai.mcp.client.type=ASYNC").run(context -> {
106-
assertThat(context).hasBean("mcpToolNamePrefixGenerator");
107-
McpToolNamePrefixGenerator generator = context.getBean(McpToolNamePrefixGenerator.class);
108-
assertThat(generator).isNotNull();
113+
// Test when toolcallback is disabled
114+
this.applicationContext.withPropertyValues("spring.ai.mcp.client.toolcallback.enabled=false").run(context -> {
115+
assertThat(context).doesNotHaveBean("mcpToolCallbacks");
116+
assertThat(context).doesNotHaveBean("mcpAsyncToolCallbacks");
109117
});
118+
119+
// Test when both are disabled
120+
this.applicationContext
121+
.withPropertyValues("spring.ai.mcp.client.enabled=false", "spring.ai.mcp.client.toolcallback.enabled=false")
122+
.run(context -> {
123+
assertThat(context).doesNotHaveBean("mcpToolCallbacks");
124+
assertThat(context).doesNotHaveBean("mcpAsyncToolCallbacks");
125+
});
110126
}
111127

112128
@Test
@@ -137,28 +153,97 @@ void customMcpToolNamePrefixGeneratorOverridesDefault() {
137153
}
138154

139155
@Test
140-
void mcpToolNamePrefixGeneratorIsInjectedIntoProviders() {
141-
// Test SYNC provider receives the generator
142-
this.applicationContext.run(context -> {
143-
assertThat(context).hasBean("mcpToolNamePrefixGenerator");
156+
void customMcpToolFilterOverridesDefault() {
157+
// Test with SYNC mode
158+
this.applicationContext.withUserConfiguration(CustomToolFilterConfig.class).run(context -> {
159+
assertThat(context).hasBean("customToolFilter");
160+
McpToolFilter filter = context.getBean("customToolFilter", McpToolFilter.class);
161+
assertThat(filter).isInstanceOf(CustomToolFilter.class);
144162
assertThat(context).hasBean("mcpToolCallbacks");
163+
// Verify the custom filter is injected into the provider
164+
SyncMcpToolCallbackProvider provider = context.getBean(SyncMcpToolCallbackProvider.class);
165+
assertThat(provider).isNotNull();
166+
});
145167

146-
McpToolNamePrefixGenerator generator = context.getBean(McpToolNamePrefixGenerator.class);
168+
// Test with ASYNC mode
169+
this.applicationContext.withUserConfiguration(CustomToolFilterConfig.class)
170+
.withPropertyValues("spring.ai.mcp.client.type=ASYNC")
171+
.run(context -> {
172+
assertThat(context).hasBean("customToolFilter");
173+
McpToolFilter filter = context.getBean("customToolFilter", McpToolFilter.class);
174+
assertThat(filter).isInstanceOf(CustomToolFilter.class);
175+
assertThat(context).hasBean("mcpAsyncToolCallbacks");
176+
// Verify the custom filter is injected into the provider
177+
AsyncMcpToolCallbackProvider provider = context.getBean(AsyncMcpToolCallbackProvider.class);
178+
assertThat(provider).isNotNull();
179+
});
180+
}
181+
182+
@Test
183+
void customToolContextToMcpMetaConverterOverridesDefault() {
184+
// Test with SYNC mode
185+
this.applicationContext.withUserConfiguration(CustomConverterConfig.class).run(context -> {
186+
assertThat(context).hasBean("customConverter");
187+
ToolContextToMcpMetaConverter converter = context.getBean("customConverter",
188+
ToolContextToMcpMetaConverter.class);
189+
assertThat(converter).isInstanceOf(CustomToolContextToMcpMetaConverter.class);
190+
assertThat(context).hasBean("mcpToolCallbacks");
191+
// Verify the custom converter is injected into the provider
192+
SyncMcpToolCallbackProvider provider = context.getBean(SyncMcpToolCallbackProvider.class);
193+
assertThat(provider).isNotNull();
194+
});
195+
196+
// Test with ASYNC mode
197+
this.applicationContext.withUserConfiguration(CustomConverterConfig.class)
198+
.withPropertyValues("spring.ai.mcp.client.type=ASYNC")
199+
.run(context -> {
200+
assertThat(context).hasBean("customConverter");
201+
ToolContextToMcpMetaConverter converter = context.getBean("customConverter",
202+
ToolContextToMcpMetaConverter.class);
203+
assertThat(converter).isInstanceOf(CustomToolContextToMcpMetaConverter.class);
204+
assertThat(context).hasBean("mcpAsyncToolCallbacks");
205+
// Verify the custom converter is injected into the provider
206+
AsyncMcpToolCallbackProvider provider = context.getBean(AsyncMcpToolCallbackProvider.class);
207+
assertThat(provider).isNotNull();
208+
});
209+
}
210+
211+
@Test
212+
void providersCreatedWithMcpClients() {
213+
// Test SYNC mode with MCP clients
214+
this.applicationContext.withUserConfiguration(McpSyncClientConfig.class).run(context -> {
215+
assertThat(context).hasBean("mcpToolCallbacks");
216+
assertThat(context).hasBean("mcpSyncClient1");
217+
assertThat(context).hasBean("mcpSyncClient2");
147218
SyncMcpToolCallbackProvider provider = context.getBean(SyncMcpToolCallbackProvider.class);
219+
assertThat(provider).isNotNull();
220+
});
221+
222+
// Test ASYNC mode with MCP clients
223+
this.applicationContext.withUserConfiguration(McpAsyncClientConfig.class)
224+
.withPropertyValues("spring.ai.mcp.client.type=ASYNC")
225+
.run(context -> {
226+
assertThat(context).hasBean("mcpAsyncToolCallbacks");
227+
assertThat(context).hasBean("mcpAsyncClient1");
228+
assertThat(context).hasBean("mcpAsyncClient2");
229+
AsyncMcpToolCallbackProvider provider = context.getBean(AsyncMcpToolCallbackProvider.class);
230+
assertThat(provider).isNotNull();
231+
});
232+
}
148233

149-
assertThat(generator).isNotNull();
234+
@Test
235+
void providersCreatedWithoutMcpClients() {
236+
// Test SYNC mode without MCP clients
237+
this.applicationContext.run(context -> {
238+
assertThat(context).hasBean("mcpToolCallbacks");
239+
SyncMcpToolCallbackProvider provider = context.getBean(SyncMcpToolCallbackProvider.class);
150240
assertThat(provider).isNotNull();
151241
});
152242

153-
// Test ASYNC provider receives the generator
243+
// Test ASYNC mode without MCP clients
154244
this.applicationContext.withPropertyValues("spring.ai.mcp.client.type=ASYNC").run(context -> {
155-
assertThat(context).hasBean("mcpToolNamePrefixGenerator");
156245
assertThat(context).hasBean("mcpAsyncToolCallbacks");
157-
158-
McpToolNamePrefixGenerator generator = context.getBean(McpToolNamePrefixGenerator.class);
159246
AsyncMcpToolCallbackProvider provider = context.getBean(AsyncMcpToolCallbackProvider.class);
160-
161-
assertThat(generator).isNotNull();
162247
assertThat(provider).isNotNull();
163248
});
164249
}
@@ -182,4 +267,84 @@ public String prefixedToolName(McpConnectionInfo mcpConnInfo, Tool tool) {
182267

183268
}
184269

270+
@Configuration
271+
static class CustomToolFilterConfig {
272+
273+
@Bean
274+
public McpToolFilter customToolFilter() {
275+
return new CustomToolFilter();
276+
}
277+
278+
}
279+
280+
static class CustomToolFilter implements McpToolFilter {
281+
282+
@Override
283+
public boolean test(McpConnectionInfo metadata, McpSchema.Tool tool) {
284+
// Custom filter logic
285+
return !tool.name().startsWith("excluded_");
286+
}
287+
288+
}
289+
290+
@Configuration
291+
static class CustomConverterConfig {
292+
293+
@Bean
294+
public ToolContextToMcpMetaConverter customConverter() {
295+
return new CustomToolContextToMcpMetaConverter();
296+
}
297+
298+
}
299+
300+
static class CustomToolContextToMcpMetaConverter implements ToolContextToMcpMetaConverter {
301+
302+
@Override
303+
public Map<String, Object> convert(ToolContext toolContext) {
304+
// Custom conversion logic
305+
return Map.of("custom", "metadata");
306+
}
307+
308+
}
309+
310+
@Configuration
311+
static class McpSyncClientConfig {
312+
313+
@Bean
314+
public List<McpSyncClient> mcpSyncClients() {
315+
return List.of(mcpSyncClient1(), mcpSyncClient2());
316+
}
317+
318+
@Bean
319+
public McpSyncClient mcpSyncClient1() {
320+
return mock(McpSyncClient.class);
321+
}
322+
323+
@Bean
324+
public McpSyncClient mcpSyncClient2() {
325+
return mock(McpSyncClient.class);
326+
}
327+
328+
}
329+
330+
@Configuration
331+
static class McpAsyncClientConfig {
332+
333+
@Bean
334+
public List<McpAsyncClient> mcpAsyncClients() {
335+
return List.of(mcpAsyncClient1(), mcpAsyncClient2());
336+
}
337+
338+
@Bean
339+
public McpAsyncClient mcpAsyncClient1() {
340+
return mock(McpAsyncClient.class);
341+
}
342+
343+
@Bean
344+
public McpAsyncClient mcpAsyncClient2() {
345+
return mock(McpAsyncClient.class);
346+
}
347+
348+
}
349+
185350
}

auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfigurationIT.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,11 @@ List<ToolCallback> testTool() {
395395
Mockito.when(mockClient.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult);
396396
when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0"));
397397

398-
return List.of(new SyncMcpToolCallback(mockClient, mockTool, mockTool.name()));
398+
return List.of(SyncMcpToolCallback.builder()
399+
.mcpClient(mockClient)
400+
.tool(mockTool)
401+
.prefixedToolName(mockTool.name())
402+
.build());
399403
}
400404

401405
}
@@ -413,7 +417,11 @@ ToolCallbackProvider testToolCallbackProvider() {
413417
Mockito.when(mockTool.description()).thenReturn("Provider Tool");
414418
when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0"));
415419

416-
return new ToolCallback[] { new SyncMcpToolCallback(mockClient, mockTool, mockTool.name()) };
420+
return new ToolCallback[] { SyncMcpToolCallback.builder()
421+
.mcpClient(mockClient)
422+
.tool(mockTool)
423+
.prefixedToolName(mockTool.name())
424+
.build() };
417425
};
418426
}
419427

auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/McpStatelessServerAutoConfigurationIT.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ List<ToolCallback> testTool() {
345345
Mockito.when(mockClient.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult);
346346
when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0"));
347347

348-
return List.of(new SyncMcpToolCallback(mockClient, mockTool));
348+
return List.of(SyncMcpToolCallback.builder().mcpClient(mockClient).tool(mockTool).build());
349349
}
350350

351351
}
@@ -363,7 +363,8 @@ ToolCallbackProvider testToolCallbackProvider() {
363363
Mockito.when(mockTool.description()).thenReturn("Provider Tool");
364364
when(mockClient.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient", "1.0.0"));
365365

366-
return new ToolCallback[] { new SyncMcpToolCallback(mockClient, mockTool) };
366+
return new ToolCallback[] {
367+
SyncMcpToolCallback.builder().mcpClient(mockClient).tool(mockTool).build() };
367368
};
368369
}
369370

0 commit comments

Comments
 (0)