From 95e05b19c312526d1f2215c38f3caa63ec3b0a14 Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Mon, 8 Sep 2025 18:47:39 +0200 Subject: [PATCH] feat(mcp): enhance tool naming with server title and prefix shortening - Add title parameter to McpSchema.Implementation constructor to distinguish between client name and server name - Update prefixedToolName() to include optional title parameter and shorten long prefixes - Implement shorten() method to abbreviate prefixes by taking first letter of each word - Ensure generated tool names stay within 64-character limit - Update all related tests to use new constructor signature and verify shortened names This change improves tool name uniqueness when multiple MCP servers provide tools with the same name, while keeping names concise through intelligent prefix shortening. Signed-off-by: Christian Tzolov --- .../McpClientAutoConfiguration.java | 2 +- ...lCallbackConverterAutoConfigurationIT.java | 4 +- ...lCallbackConverterAutoConfigurationIT.java | 4 +- .../ai/mcp/AsyncMcpToolCallback.java | 4 +- .../ai/mcp/McpToolNamePrefixGenerator.java | 2 +- .../springframework/ai/mcp/McpToolUtils.java | 42 ++++++++++-- .../ai/mcp/SyncMcpToolCallback.java | 4 +- .../AsyncMcpToolCallbackProviderTests.java | 8 +-- .../mcp/SyncMcpToolCallbackBuilderTest.java | 2 +- ...yncMcpToolCallbackProviderBuilderTest.java | 12 ++-- .../mcp/SyncMcpToolCallbackProviderTests.java | 12 ++-- .../ai/mcp/SyncMcpToolCallbackTests.java | 22 +++---- .../ai/mcp/ToolUtilsTests.java | 66 +++++++++---------- .../api/mcp/mcp-client-boot-starter-docs.adoc | 19 ++++-- 14 files changed, 121 insertions(+), 82 deletions(-) diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java index 1548c3b9803..e6e60920c8b 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-client-common/src/main/java/org/springframework/ai/mcp/client/common/autoconfigure/McpClientAutoConfiguration.java @@ -172,7 +172,7 @@ public List mcpSyncClients(McpSyncClientConfigurer mcpSyncClientC McpSchema.Implementation clientInfo = new McpSchema.Implementation( this.connectedClientName(commonProperties.getName(), namedTransport.name()), - commonProperties.getVersion()); + namedTransport.name(), commonProperties.getVersion()); McpClient.SyncSpec spec = McpClient.sync(namedTransport.transport()) .clientInfo(clientInfo) diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/StatelessToolCallbackConverterAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/StatelessToolCallbackConverterAutoConfigurationIT.java index a9cf7e3f5bf..e629a1086c3 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/StatelessToolCallbackConverterAutoConfigurationIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/StatelessToolCallbackConverterAutoConfigurationIT.java @@ -261,7 +261,7 @@ List testDuplicateToolCallbacks() { Mockito.when(mockTool1.name()).thenReturn("duplicate-tool"); Mockito.when(mockTool1.description()).thenReturn("First Tool"); Mockito.when(mockClient1.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult1); - when(mockClient1.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient1", "1.0.0")); + when(mockClient1.getClientInfo()).thenReturn(new McpSchema.Implementation("frist_client", "1.0.0")); McpSyncClient mockClient2 = Mockito.mock(McpSyncClient.class); McpSchema.Tool mockTool2 = Mockito.mock(McpSchema.Tool.class); @@ -270,7 +270,7 @@ List testDuplicateToolCallbacks() { Mockito.when(mockTool2.name()).thenReturn("duplicate-tool"); Mockito.when(mockTool2.description()).thenReturn("Second Tool"); Mockito.when(mockClient2.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult2); - when(mockClient2.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient2", "1.0.0")); + when(mockClient2.getClientInfo()).thenReturn(new McpSchema.Implementation("second_client", "1.0.0")); return List.of(SyncMcpToolCallback.builder().mcpClient(mockClient1).tool(mockTool1).build(), SyncMcpToolCallback.builder().mcpClient(mockClient2).tool(mockTool2).build()); diff --git a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java index a65a53e95b8..072a6f726cf 100644 --- a/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java +++ b/auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/test/java/org/springframework/ai/mcp/server/common/autoconfigure/ToolCallbackConverterAutoConfigurationIT.java @@ -261,7 +261,7 @@ List testDuplicateToolCallbacks() { Mockito.when(mockTool1.name()).thenReturn("duplicate-tool"); Mockito.when(mockTool1.description()).thenReturn("First Tool"); Mockito.when(mockClient1.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult1); - when(mockClient1.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient1", "1.0.0")); + when(mockClient1.getClientInfo()).thenReturn(new McpSchema.Implementation("client", "server1", "1.0.0")); McpSyncClient mockClient2 = Mockito.mock(McpSyncClient.class); McpSchema.Tool mockTool2 = Mockito.mock(McpSchema.Tool.class); @@ -270,7 +270,7 @@ List testDuplicateToolCallbacks() { Mockito.when(mockTool2.name()).thenReturn("duplicate-tool"); Mockito.when(mockTool2.description()).thenReturn("Second Tool"); Mockito.when(mockClient2.callTool(Mockito.any(McpSchema.CallToolRequest.class))).thenReturn(mockResult2); - when(mockClient2.getClientInfo()).thenReturn(new McpSchema.Implementation("testClient2", "1.0.0")); + when(mockClient2.getClientInfo()).thenReturn(new McpSchema.Implementation("client", "server2", "1.0.0")); return List.of(SyncMcpToolCallback.builder().mcpClient(mockClient1).tool(mockTool1).build(), SyncMcpToolCallback.builder().mcpClient(mockClient2).tool(mockTool2).build()); diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java index 85563676195..b7c73fb9246 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/AsyncMcpToolCallback.java @@ -66,8 +66,8 @@ public class AsyncMcpToolCallback implements ToolCallback { */ @Deprecated public AsyncMcpToolCallback(McpAsyncClient mcpClient, Tool tool) { - this(mcpClient, tool, McpToolUtils.prefixedToolName(mcpClient.getClientInfo().name(), tool.name()), - ToolContextToMcpMetaConverter.defaultConverter()); + this(mcpClient, tool, McpToolUtils.prefixedToolName(mcpClient.getClientInfo().name(), + mcpClient.getClientInfo().title(), tool.name()), ToolContextToMcpMetaConverter.defaultConverter()); } /** diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolNamePrefixGenerator.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolNamePrefixGenerator.java index 9e61486e687..39e2142961d 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolNamePrefixGenerator.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolNamePrefixGenerator.java @@ -47,7 +47,7 @@ public interface McpToolNamePrefixGenerator { */ static McpToolNamePrefixGenerator defaultGenerator() { return (mcpConnectionIfo, tool) -> McpToolUtils.prefixedToolName(mcpConnectionIfo.clientInfo().name(), - tool.name()); + mcpConnectionIfo.clientInfo().title(), tool.name()); } /** diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java index 80902a671dd..0a49faa8723 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java @@ -20,6 +20,7 @@ import java.util.Map; import java.util.Optional; import java.util.function.BiFunction; +import java.util.stream.Stream; import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -73,28 +74,55 @@ public final class McpToolUtils { private McpToolUtils() { } - public static String prefixedToolName(String prefix, String toolName) { + public static String prefixedToolName(String prefix, String title, String toolName) { if (StringUtils.isEmpty(prefix) || StringUtils.isEmpty(toolName)) { throw new IllegalArgumentException("Prefix or toolName cannot be null or empty"); } - String input = prefix + "_" + toolName; + String input = shorten(format(prefix)); + if (!StringUtils.isEmpty(title)) { + input = input + "_" + format(title); // Do not shorten the title. + } + + input = input + "_" + format(toolName); + + // If the string is longer than 64 characters, keep the last 64 characters + if (input.length() > 64) { + input = input.substring(input.length() - 64); + } + + return input; + } + public static String prefixedToolName(String prefix, String toolName) { + return prefixedToolName(prefix, null, toolName); + } + + private static String format(String input) { // Replace any character that isn't alphanumeric, underscore, or hyphen with // concatenation. Support Han script + CJK blocks for complete Chinese character // coverage String formatted = input .replaceAll("[^\\p{IsHan}\\p{InCJK_Unified_Ideographs}\\p{InCJK_Compatibility_Ideographs}a-zA-Z0-9_-]", ""); - formatted = formatted.replaceAll("-", "_"); + return formatted.replaceAll("-", "_"); + } - // If the string is longer than 64 characters, keep the last 64 characters - if (formatted.length() > 64) { - formatted = formatted.substring(formatted.length() - 64); + /** + * Shortens a string by taking the first letter of each word separated by underscores + * @param input String in format "Word1_Word2_Word3_..." + * @return Shortened string with first letters + */ + private static String shorten(String input) { + if (input == null || input.isEmpty()) { + return ""; } - return formatted; + return Stream.of(input.toLowerCase().split("_")) + .filter(word -> !word.isEmpty()) + .map(word -> String.valueOf(word.charAt(0))) + .collect(java.util.stream.Collectors.joining("_")); } /** diff --git a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java index 6ca00235390..d7f980d08d9 100644 --- a/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java +++ b/mcp/common/src/main/java/org/springframework/ai/mcp/SyncMcpToolCallback.java @@ -63,8 +63,8 @@ public class SyncMcpToolCallback implements ToolCallback { */ @Deprecated public SyncMcpToolCallback(McpSyncClient mcpClient, Tool tool) { - this(mcpClient, tool, McpToolUtils.prefixedToolName(mcpClient.getClientInfo().name(), tool.name()), - ToolContextToMcpMetaConverter.defaultConverter()); + this(mcpClient, tool, McpToolUtils.prefixedToolName(mcpClient.getClientInfo().name(), + mcpClient.getClientInfo().title(), tool.name()), ToolContextToMcpMetaConverter.defaultConverter()); } /** diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProviderTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProviderTests.java index aaddc867215..db72041c72e 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProviderTests.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/AsyncMcpToolCallbackProviderTests.java @@ -225,8 +225,8 @@ void toolFilterShouldFilterToolsByNameWhenConfigured() { var callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(2); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("testClient_tool2"); - assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("testClient_tool3"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("t_tool2"); + assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("t_tool3"); } @Test @@ -266,7 +266,7 @@ void toolFilterShouldFilterToolsByClientWhenConfigured() { var callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(1); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("testClient1_tool1"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("t_tool1"); } @Test @@ -299,7 +299,7 @@ void toolFilterShouldCombineClientAndToolCriteriaWhenConfigured() { var callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(1); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("weather_service_weather"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("w_s_weather"); } @Test diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackBuilderTest.java b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackBuilderTest.java index 17154320701..d5f3018ba4f 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackBuilderTest.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackBuilderTest.java @@ -49,7 +49,7 @@ void builderShouldCreateInstanceWithRequiredFields() { assertThat(callback).isNotNull(); assertThat(callback.getOriginalToolName()).isEqualTo("test-tool"); assertThat(callback.getToolDefinition()).isNotNull(); - assertThat(callback.getToolDefinition().name()).isEqualTo("test_client_test_tool"); + assertThat(callback.getToolDefinition().name()).isEqualTo("t_c_test_tool"); assertThat(callback.getToolDefinition().description()).isEqualTo("Test tool description"); } diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderBuilderTest.java b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderBuilderTest.java index e5184b5fb4f..ba3e15a06e0 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderBuilderTest.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderBuilderTest.java @@ -47,7 +47,7 @@ void builderShouldCreateInstanceWithSingleClient() { assertThat(provider).isNotNull(); ToolCallback[] callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(1); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("test_client_test_tool"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("t_c_test_tool"); } @Test @@ -64,8 +64,8 @@ void builderShouldCreateInstanceWithMultipleClients() { assertThat(provider).isNotNull(); ToolCallback[] callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(2); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("client1_tool1"); - assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("client2_tool2"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("c_tool1"); + assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("c_tool2"); } @Test @@ -111,7 +111,7 @@ void builderShouldCreateInstanceWithCustomToolFilter() { assertThat(provider).isNotNull(); ToolCallback[] callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(1); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("client_filtered_tool"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("c_filtered_tool"); } @Test @@ -230,8 +230,8 @@ void builderShouldReplaceClientsWhenSettingNewList() { assertThat(provider).isNotNull(); ToolCallback[] callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(2); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("client2_tool2"); - assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("client3_tool3"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("c_tool2"); + assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("c_tool3"); } private McpSyncClient createMockClient(String clientName, String toolName) { diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderTests.java index 7ea2dca5d98..69a4488c22e 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderTests.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackProviderTests.java @@ -116,7 +116,7 @@ void getSameNameToolsButDifferentClientInfoNamesShouldProduceDifferentToolCallba when(listToolsResult1.tools()).thenReturn(List.of(tool1)); when(mcpClient1.listTools()).thenReturn(listToolsResult1); - var clientInfo1 = new Implementation("testClient1", "1.0.0"); + var clientInfo1 = new Implementation("FirstClient", "1.0.0"); when(mcpClient1.getClientInfo()).thenReturn(clientInfo1); McpSyncClient mcpClient2 = mock(McpSyncClient.class); @@ -124,7 +124,7 @@ void getSameNameToolsButDifferentClientInfoNamesShouldProduceDifferentToolCallba when(listToolsResult2.tools()).thenReturn(List.of(tool2)); when(mcpClient2.listTools()).thenReturn(listToolsResult2); - var clientInfo2 = new Implementation("testClient2", "1.0.0"); + var clientInfo2 = new Implementation("SecondClient", "1.0.0"); when(mcpClient2.getClientInfo()).thenReturn(clientInfo2); SyncMcpToolCallbackProvider provider = SyncMcpToolCallbackProvider.builder() @@ -213,8 +213,8 @@ void toolFilterShouldFilterToolsByNameWhenConfigured() { var callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(2); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("testClient_tool2"); - assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("testClient_tool3"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("t_tool2"); + assertThat(callbacks[1].getToolDefinition().name()).isEqualTo("t_tool3"); } @Test @@ -253,7 +253,7 @@ void toolFilterShouldFilterToolsByClientWhenConfigured() { var callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(1); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("testClient1_tool1"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("t_tool1"); } @Test @@ -286,7 +286,7 @@ void toolFilterShouldCombineClientAndToolCriteriaWhenConfigured() { var callbacks = provider.getToolCallbacks(); assertThat(callbacks).hasSize(1); - assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("weather_service_weather"); + assertThat(callbacks[0].getToolDefinition().name()).isEqualTo("w_s_weather"); } @Test diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java index 27637f47dd6..3dae5d0eba1 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java @@ -57,13 +57,13 @@ void getToolDefinitionShouldReturnCorrectDefinition() { SyncMcpToolCallback callback = SyncMcpToolCallback.builder() .mcpClient(this.mcpClient) .tool(this.tool) - .prefixedToolName(McpToolUtils.prefixedToolName(clientInfo.name(), this.tool.name())) + .prefixedToolName(McpToolUtils.prefixedToolName(clientInfo.name(), clientInfo.title(), this.tool.name())) .toolContextToMcpMetaConverter(ToolContextToMcpMetaConverter.defaultConverter()) .build(); var toolDefinition = callback.getToolDefinition(); - assertThat(toolDefinition.name()).isEqualTo(clientInfo.name() + "_testTool"); + assertThat(toolDefinition.name()).isEqualTo("t_testTool"); assertThat(toolDefinition.description()).isEqualTo("Test tool description"); } @@ -89,7 +89,7 @@ void callShouldHandleJsonInputAndOutput() { SyncMcpToolCallback callback = SyncMcpToolCallback.builder() .mcpClient(this.mcpClient) .tool(this.tool) - .prefixedToolName(McpToolUtils.prefixedToolName("testClient", this.tool.name())) + .prefixedToolName(McpToolUtils.prefixedToolName("testClient", "server1", this.tool.name())) .toolContextToMcpMetaConverter(ToolContextToMcpMetaConverter.defaultConverter()) .build(); @@ -107,7 +107,7 @@ void callShouldHandleToolContext() { SyncMcpToolCallback callback = SyncMcpToolCallback.builder() .mcpClient(this.mcpClient) .tool(this.tool) - .prefixedToolName(McpToolUtils.prefixedToolName("testClient", this.tool.name())) + .prefixedToolName(McpToolUtils.prefixedToolName("testClient", "server1", this.tool.name())) .toolContextToMcpMetaConverter(ToolContextToMcpMetaConverter.defaultConverter()) .build(); @@ -145,7 +145,7 @@ void callShouldHandleNullOrEmptyInput() { @Test void callShouldThrowOnError() { when(this.tool.name()).thenReturn("testTool"); - var clientInfo = new Implementation("testClient", "1.0.0"); + var clientInfo = new Implementation("testClient", "server1", "1.0.0"); CallToolResult callResult = mock(CallToolResult.class); when(callResult.isError()).thenReturn(true); when(callResult.content()).thenReturn(List.of(new McpSchema.TextContent("Some error data"))); @@ -154,7 +154,7 @@ void callShouldThrowOnError() { SyncMcpToolCallback callback = SyncMcpToolCallback.builder() .mcpClient(this.mcpClient) .tool(this.tool) - .prefixedToolName(McpToolUtils.prefixedToolName(clientInfo.name(), this.tool.name())) + .prefixedToolName(McpToolUtils.prefixedToolName(clientInfo.name(), clientInfo.title(), this.tool.name())) .toolContextToMcpMetaConverter(ToolContextToMcpMetaConverter.defaultConverter()) .build(); @@ -167,13 +167,13 @@ void callShouldThrowOnError() { @Test void callShouldWrapExceptions() { when(this.tool.name()).thenReturn("testTool"); - var clientInfo = new Implementation("testClient", "1.0.0"); + var clientInfo = new Implementation("testClient", "server1", "1.0.0"); when(this.mcpClient.callTool(any(CallToolRequest.class))).thenThrow(new RuntimeException("Testing tool error")); SyncMcpToolCallback callback = SyncMcpToolCallback.builder() .mcpClient(this.mcpClient) .tool(this.tool) - .prefixedToolName(McpToolUtils.prefixedToolName(clientInfo.name(), this.tool.name())) + .prefixedToolName(McpToolUtils.prefixedToolName(clientInfo.name(), clientInfo.title(), this.tool.name())) .toolContextToMcpMetaConverter(ToolContextToMcpMetaConverter.defaultConverter()) .build(); @@ -193,7 +193,7 @@ void callShouldHandleEmptyResponse() { SyncMcpToolCallback callback = SyncMcpToolCallback.builder() .mcpClient(this.mcpClient) .tool(this.tool) - .prefixedToolName(McpToolUtils.prefixedToolName("testClient", this.tool.name())) + .prefixedToolName(McpToolUtils.prefixedToolName("testClient", "server1", this.tool.name())) .toolContextToMcpMetaConverter(ToolContextToMcpMetaConverter.defaultConverter()) .build(); @@ -214,7 +214,7 @@ void callShouldHandleMultipleContentItems() { SyncMcpToolCallback callback = SyncMcpToolCallback.builder() .mcpClient(this.mcpClient) .tool(this.tool) - .prefixedToolName(McpToolUtils.prefixedToolName("testClient", this.tool.name())) + .prefixedToolName(McpToolUtils.prefixedToolName("testClient", "server1", this.tool.name())) .toolContextToMcpMetaConverter(ToolContextToMcpMetaConverter.defaultConverter()) .build(); @@ -235,7 +235,7 @@ void callShouldHandleNonTextContent() { SyncMcpToolCallback callback = SyncMcpToolCallback.builder() .mcpClient(this.mcpClient) .tool(this.tool) - .prefixedToolName(McpToolUtils.prefixedToolName("testClient", this.tool.name())) + .prefixedToolName(McpToolUtils.prefixedToolName("testClient", "server1", this.tool.name())) .toolContextToMcpMetaConverter(ToolContextToMcpMetaConverter.defaultConverter()) .build(); diff --git a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java index 6dcac5c376c..12c1f6023e7 100644 --- a/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java +++ b/mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java @@ -49,26 +49,26 @@ class ToolUtilsTests { @Test void prefixedToolNameShouldConcatenateWithUnderscore() { - String result = McpToolUtils.prefixedToolName("prefix", "toolName"); - assertThat(result).isEqualTo("prefix_toolName"); + String result = McpToolUtils.prefixedToolName("prefix", "server1", "toolName"); + assertThat(result).isEqualTo("p_server1_toolName"); } @Test void prefixedToolNameShouldReplaceSpecialCharacters() { - String result = McpToolUtils.prefixedToolName("pre.fix", "tool@Name"); - assertThat(result).isEqualTo("prefix_toolName"); + String result = McpToolUtils.prefixedToolName("pre.fix", "server1", "tool@Name"); + assertThat(result).isEqualTo("p_server1_toolName"); } @Test void prefixedToolNameShouldReplaceHyphensWithUnderscores() { - String result = McpToolUtils.prefixedToolName("pre-fix", "tool-name"); - assertThat(result).isEqualTo("pre_fix_tool_name"); + String result = McpToolUtils.prefixedToolName("p", "tool-name"); + assertThat(result).isEqualTo("p_tool_name"); } @Test void prefixedToolNameShouldTruncateLongStrings() { String longPrefix = "a".repeat(40); - String longToolName = "b".repeat(40); + String longToolName = "b".repeat(62); String result = McpToolUtils.prefixedToolName(longPrefix, longToolName); assertThat(result).hasSize(64); assertThat(result).endsWith("_" + longToolName); @@ -96,25 +96,25 @@ void prefixedToolNameShouldThrowExceptionForNullOrEmptyInputs() { @Test void prefixedToolNameShouldSupportChineseCharacters() { String result = McpToolUtils.prefixedToolName("前缀", "工具名称"); - assertThat(result).isEqualTo("前缀_工具名称"); + assertThat(result).isEqualTo("前_工具名称"); } @Test void prefixedToolNameShouldSupportMixedChineseAndEnglish() { String result = McpToolUtils.prefixedToolName("prefix前缀", "tool工具Name"); - assertThat(result).isEqualTo("prefix前缀_tool工具Name"); + assertThat(result).isEqualTo("p_tool工具Name"); } @Test void prefixedToolNameShouldRemoveSpecialCharactersButKeepChinese() { String result = McpToolUtils.prefixedToolName("pre@fix前缀", "tool#工具$name"); - assertThat(result).isEqualTo("prefix前缀_tool工具name"); + assertThat(result).isEqualTo("p_tool工具name"); } @Test void prefixedToolNameShouldHandleChineseWithHyphens() { String result = McpToolUtils.prefixedToolName("前缀-test", "工具-name"); - assertThat(result).isEqualTo("前缀_test_工具_name"); + assertThat(result).isEqualTo("前_t_工具_name"); } @Test @@ -123,14 +123,14 @@ void prefixedToolNameShouldTruncateLongChineseStrings() { String longPrefix = "前缀".repeat(20); // 40 Chinese characters String longToolName = "工具".repeat(20); // 40 Chinese characters String result = McpToolUtils.prefixedToolName(longPrefix, longToolName); - assertThat(result).hasSize(64); + assertThat(result).hasSize(42); assertThat(result).endsWith("_" + "工具".repeat(20)); } @Test void prefixedToolNameShouldHandleChinesePunctuation() { String result = McpToolUtils.prefixedToolName("前缀,测试", "工具。名称!"); - assertThat(result).isEqualTo("前缀测试_工具名称"); + assertThat(result).isEqualTo("前_工具名称"); } @Test @@ -139,12 +139,12 @@ void prefixedToolNameShouldHandleUnicodeBoundaries() { String result1 = McpToolUtils.prefixedToolName("prefix", "tool\u4e00"); // First // Chinese // character - assertThat(result1).isEqualTo("prefix_tool\u4e00"); + assertThat(result1).isEqualTo("p_tool\u4e00"); String result2 = McpToolUtils.prefixedToolName("prefix", "tool\u9fa5"); // Last // Chinese // character - assertThat(result2).isEqualTo("prefix_tool\u9fa5"); + assertThat(result2).isEqualTo("p_tool\u9fa5"); } @Test @@ -152,30 +152,30 @@ void prefixedToolNameShouldExcludeNonChineseUnicodeCharacters() { // Test with Japanese Hiragana (outside Chinese range) String result1 = McpToolUtils.prefixedToolName("prefix", "toolあ"); // Japanese // Hiragana - assertThat(result1).isEqualTo("prefix_tool"); + assertThat(result1).isEqualTo("p_tool"); // Test with Korean characters (outside Chinese range) String result2 = McpToolUtils.prefixedToolName("prefix", "tool한"); // Korean // character - assertThat(result2).isEqualTo("prefix_tool"); + assertThat(result2).isEqualTo("p_tool"); // Test with Arabic characters (outside Chinese range) String result3 = McpToolUtils.prefixedToolName("prefix", "toolع"); // Arabic // character - assertThat(result3).isEqualTo("prefix_tool"); + assertThat(result3).isEqualTo("p_tool"); } @Test void prefixedToolNameShouldHandleEmojisAndSymbols() { // Emojis and symbols should be removed String result = McpToolUtils.prefixedToolName("prefix🚀", "tool工具😀name"); - assertThat(result).isEqualTo("prefix_tool工具name"); + assertThat(result).isEqualTo("p_tool工具name"); } @Test void prefixedToolNameShouldPreserveNumbersWithChinese() { String result = McpToolUtils.prefixedToolName("前缀123", "工具456名称"); - assertThat(result).isEqualTo("前缀123_工具456名称"); + assertThat(result).isEqualTo("前_工具456名称"); } @Test @@ -184,12 +184,12 @@ void prefixedToolNameShouldSupportExtendedHanCharacters() { String result1 = McpToolUtils.prefixedToolName("prefix", "tool\u9fff"); // CJK // block // boundary - assertThat(result1).isEqualTo("prefix_tool\u9fff"); + assertThat(result1).isEqualTo("p_tool\u9fff"); // Test CJK Extension A characters String result2 = McpToolUtils.prefixedToolName("prefix", "tool\u3400"); // CJK Ext // A - assertThat(result2).isEqualTo("prefix_tool\u3400"); + assertThat(result2).isEqualTo("p_tool\u3400"); } @Test @@ -197,15 +197,15 @@ void prefixedToolNameShouldSupportCompatibilityIdeographs() { // Test CJK Compatibility Ideographs String result = McpToolUtils.prefixedToolName("prefix", "tool\uf900"); // Compatibility // ideograph - assertThat(result).isEqualTo("prefix_tool\uf900"); + assertThat(result).isEqualTo("p_tool\uf900"); } @Test void prefixedToolNameShouldHandleAllHanScriptCharacters() { // Mix of different Han character blocks: Extension A + CJK Unified + // Compatibility - String result = McpToolUtils.prefixedToolName("前缀\u3400", "工具\u9fff名称\uf900"); - assertThat(result).isEqualTo("前缀\u3400_工具\u9fff名称\uf900"); + String result = McpToolUtils.prefixedToolName("前缀\u3400", "缀\\u3400", "工具\u9fff名称\uf900"); + assertThat(result).isEqualTo("前_缀u3400_工具鿿名称豈"); } @Test @@ -359,14 +359,14 @@ void getToolCallbacksFromSyncClientsWithSingleClientShouldReturnToolCallbacks() List result = McpToolUtils.getToolCallbacksFromSyncClients(mockClient); assertThat(result).hasSize(2); - assertThat(result.get(0).getToolDefinition().name()).isEqualTo("test_client_tool1"); - assertThat(result.get(1).getToolDefinition().name()).isEqualTo("test_client_tool2"); + assertThat(result.get(0).getToolDefinition().name()).isEqualTo("t_c_tool1"); + assertThat(result.get(1).getToolDefinition().name()).isEqualTo("t_c_tool2"); List result2 = McpToolUtils.getToolCallbacksFromSyncClients(List.of(mockClient)); assertThat(result2).hasSize(2); - assertThat(result2.get(0).getToolDefinition().name()).isEqualTo("test_client_tool1"); - assertThat(result2.get(1).getToolDefinition().name()).isEqualTo("test_client_tool2"); + assertThat(result2.get(0).getToolDefinition().name()).isEqualTo("t_c_tool1"); + assertThat(result2.get(1).getToolDefinition().name()).isEqualTo("t_c_tool2"); } @Test @@ -401,14 +401,14 @@ void getToolCallbacksFromSyncClientsWithMultipleClientsShouldReturnCombinedToolC List result = McpToolUtils.getToolCallbacksFromSyncClients(mockClient1, mockClient2); assertThat(result).hasSize(2); - assertThat(result.get(0).getToolDefinition().name()).isEqualTo("client1_tool1"); - assertThat(result.get(1).getToolDefinition().name()).isEqualTo("client2_tool2"); + assertThat(result.get(0).getToolDefinition().name()).isEqualTo("c_tool1"); + assertThat(result.get(1).getToolDefinition().name()).isEqualTo("c_tool2"); List result2 = McpToolUtils.getToolCallbacksFromSyncClients(List.of(mockClient1, mockClient2)); assertThat(result2).hasSize(2); - assertThat(result2.get(0).getToolDefinition().name()).isEqualTo("client1_tool1"); - assertThat(result2.get(1).getToolDefinition().name()).isEqualTo("client2_tool2"); + assertThat(result2.get(0).getToolDefinition().name()).isEqualTo("c_tool1"); + assertThat(result2.get(1).getToolDefinition().name()).isEqualTo("c_tool2"); } @Test diff --git a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc index c0b155be798..b7a4698eb9d 100644 --- a/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc +++ b/spring-ai-docs/src/main/antora/modules/ROOT/pages/api/mcp/mcp-client-boot-starter-docs.adoc @@ -435,7 +435,18 @@ If multiple filters are needed, combine them into a single composite filter impl The MCP Client Boot Starter supports customizable tool name prefix generation through the `McpToolNamePrefixGenerator` interface. This feature helps avoid naming conflicts when integrating tools from multiple MCP servers by adding unique prefixes to tool names. -By default, if no custom `McpToolNamePrefixGenerator` bean is provided, the starter uses `McpToolNamePrefixGenerator.defaultGenerator()` which uses the MCP client name as a prefix for tool names. You can customize this behavior by providing your own implementation: +By default, if no custom `McpToolNamePrefixGenerator` bean is provided, the starter uses `McpToolNamePrefixGenerator.defaultGenerator()` which generates a prefix from the MCP client name and title. The default generator: + +* Shortens the client name by taking the first letter of each word (separated by underscores). The Spring AI injects the connection name at the end of the client name by default. +* Includes the server title (if available) without shortening. Spring AI sets the title to the connection name by default. +* Formats the final tool name as: `shortened_prefix_title_toolName` +* Ensures the final name doesn't exceed 64 characters (truncating from the beginning if necessary) + +For example: +* Client name: `spring_ai_mcp_client_server1`, Title: `server1`, Tool: `search` → `s_a_m_c_s_server1_search` +* Client name: `weather_service`, Title: (none), Tool: `forecast` → `w_s_forecast` + +You can customize this behavior by providing your own implementation: [source,java] ---- @@ -457,14 +468,14 @@ public class CustomToolNamePrefixGenerator implements McpToolNamePrefixGenerator The `McpConnectionInfo` record provides comprehensive information about the MCP connection: * `clientCapabilities` - The capabilities of the MCP client -* `clientInfo` - Information about the MCP client (name and version) +* `clientInfo` - Information about the MCP client (name, title, and version) * `initializeResult` - The initialization result from the MCP server, including server information ==== Built-in Prefix Generators The framework provides several built-in prefix generators: -* `McpToolNamePrefixGenerator.defaultGenerator()` - Uses the MCP client name as prefix (used by default if no custom bean is provided) +* `McpToolNamePrefixGenerator.defaultGenerator()` - Uses shortened client name and title as prefix (used by default if no custom bean is provided) * `McpToolNamePrefixGenerator.noPrefix()` - Returns tool names without any prefix To disable prefixing entirely, register the no-prefix generator as a bean: @@ -482,7 +493,7 @@ public class McpConfiguration { ---- The prefix generator is automatically detected and applied to both synchronous and asynchronous MCP tool callback providers through Spring's `ObjectProvider` mechanism. -If no custom generator bean is provided, the default generator (using prefix generated from client's info ) is used automatically. +If no custom generator bean is provided, the default generator is used automatically. === Tool Context to MCP Meta Converter