Skip to content

Commit dae5fef

Browse files
committed
refactor: standardize tool names to use underscores instead of hyphens
- Change separator in McpToolUtils.prefixedToolName from hyphen to underscore - Add conversion of any remaining hyphens to underscores in formatted tool names - Update affected tests to reflect the new naming convention - Add comprehensive tests for McpToolUtils.prefixedToolName method - Add integration test for payment transaction tools with Vertex AI Gemini Signed-off-by: Christian Tzolov <[email protected]>
1 parent 55a11f6 commit dae5fef

File tree

4 files changed

+289
-2
lines changed

4 files changed

+289
-2
lines changed

mcp/common/src/main/java/org/springframework/ai/mcp/McpToolUtils.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,14 @@ public static String prefixedToolName(String prefix, String toolName) {
6767
throw new IllegalArgumentException("Prefix or toolName cannot be null or empty");
6868
}
6969

70-
String input = prefix + "-" + toolName;
70+
String input = prefix + "_" + toolName;
7171

7272
// Replace any character that isn't alphanumeric, underscore, or hyphen with
7373
// concatenation
7474
String formatted = input.replaceAll("[^a-zA-Z0-9_-]", "");
7575

76+
formatted = formatted.replaceAll("-", "_");
77+
7678
// If the string is longer than 64 characters, keep the last 64 characters
7779
if (formatted.length() > 64) {
7880
formatted = formatted.substring(formatted.length() - 64);

mcp/common/src/test/java/org/springframework/ai/mcp/SyncMcpToolCallbackTests.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ void getToolDefinitionShouldReturnCorrectDefinition() {
5656

5757
var toolDefinition = callback.getToolDefinition();
5858

59-
assertThat(toolDefinition.name()).isEqualTo(clientInfo.name() + "-testTool");
59+
assertThat(toolDefinition.name()).isEqualTo(clientInfo.name() + "_testTool");
6060
assertThat(toolDefinition.description()).isEqualTo("Test tool description");
6161
}
6262

mcp/common/src/test/java/org/springframework/ai/mcp/ToolUtilsTests.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,60 @@
3434
import org.springframework.ai.tool.definition.ToolDefinition;
3535

3636
import static org.assertj.core.api.Assertions.assertThat;
37+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
3738
import static org.mockito.ArgumentMatchers.any;
3839
import static org.mockito.ArgumentMatchers.anyString;
3940
import static org.mockito.Mockito.mock;
4041
import static org.mockito.Mockito.when;
4142

4243
class ToolUtilsTests {
4344

45+
@Test
46+
void prefixedToolNameShouldConcatenateWithUnderscore() {
47+
String result = McpToolUtils.prefixedToolName("prefix", "toolName");
48+
assertThat(result).isEqualTo("prefix_toolName");
49+
}
50+
51+
@Test
52+
void prefixedToolNameShouldReplaceSpecialCharacters() {
53+
String result = McpToolUtils.prefixedToolName("pre.fix", "tool@Name");
54+
assertThat(result).isEqualTo("prefix_toolName");
55+
}
56+
57+
@Test
58+
void prefixedToolNameShouldReplaceHyphensWithUnderscores() {
59+
String result = McpToolUtils.prefixedToolName("pre-fix", "tool-name");
60+
assertThat(result).isEqualTo("pre_fix_tool_name");
61+
}
62+
63+
@Test
64+
void prefixedToolNameShouldTruncateLongStrings() {
65+
String longPrefix = "a".repeat(40);
66+
String longToolName = "b".repeat(40);
67+
String result = McpToolUtils.prefixedToolName(longPrefix, longToolName);
68+
assertThat(result).hasSize(64);
69+
assertThat(result).endsWith("_" + longToolName);
70+
}
71+
72+
@Test
73+
void prefixedToolNameShouldThrowExceptionForNullOrEmptyInputs() {
74+
assertThatThrownBy(() -> McpToolUtils.prefixedToolName(null, "toolName"))
75+
.isInstanceOf(IllegalArgumentException.class)
76+
.hasMessageContaining("Prefix or toolName cannot be null or empty");
77+
78+
assertThatThrownBy(() -> McpToolUtils.prefixedToolName("", "toolName"))
79+
.isInstanceOf(IllegalArgumentException.class)
80+
.hasMessageContaining("Prefix or toolName cannot be null or empty");
81+
82+
assertThatThrownBy(() -> McpToolUtils.prefixedToolName("prefix", null))
83+
.isInstanceOf(IllegalArgumentException.class)
84+
.hasMessageContaining("Prefix or toolName cannot be null or empty");
85+
86+
assertThatThrownBy(() -> McpToolUtils.prefixedToolName("prefix", ""))
87+
.isInstanceOf(IllegalArgumentException.class)
88+
.hasMessageContaining("Prefix or toolName cannot be null or empty");
89+
}
90+
4491
@Test
4592
void constructorShouldBePrivate() throws Exception {
4693
Constructor<McpToolUtils> constructor = McpToolUtils.class.getDeclaredConstructor();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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.vertexai.gemini.tool;
18+
19+
import java.util.List;
20+
import java.util.Map;
21+
import java.util.stream.Collectors;
22+
23+
import com.google.cloud.vertexai.Transport;
24+
import com.google.cloud.vertexai.VertexAI;
25+
import io.micrometer.observation.ObservationRegistry;
26+
import org.junit.jupiter.api.RepeatedTest;
27+
import org.junit.jupiter.api.Test;
28+
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
29+
import org.slf4j.Logger;
30+
import org.slf4j.LoggerFactory;
31+
import reactor.core.publisher.Flux;
32+
33+
import org.springframework.ai.chat.client.ChatClient;
34+
import org.springframework.ai.chat.client.advisor.api.AdvisedRequest;
35+
import org.springframework.ai.chat.client.advisor.api.AdvisedResponse;
36+
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisor;
37+
import org.springframework.ai.chat.client.advisor.api.CallAroundAdvisorChain;
38+
import org.springframework.ai.model.function.FunctionCallback;
39+
import org.springframework.ai.model.tool.ToolCallingManager;
40+
import org.springframework.ai.tool.annotation.Tool;
41+
import org.springframework.ai.tool.execution.DefaultToolExecutionExceptionProcessor;
42+
import org.springframework.ai.tool.resolution.DelegatingToolCallbackResolver;
43+
import org.springframework.ai.tool.resolution.SpringBeanToolCallbackResolver;
44+
import org.springframework.ai.tool.resolution.StaticToolCallbackResolver;
45+
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
46+
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatModel;
47+
import org.springframework.ai.vertexai.gemini.VertexAiGeminiChatOptions;
48+
import org.springframework.beans.factory.ObjectProvider;
49+
import org.springframework.beans.factory.annotation.Autowired;
50+
import org.springframework.boot.SpringBootConfiguration;
51+
import org.springframework.boot.test.context.SpringBootTest;
52+
import org.springframework.context.annotation.Bean;
53+
import org.springframework.context.support.GenericApplicationContext;
54+
55+
import static org.assertj.core.api.Assertions.assertThat;
56+
57+
/**
58+
* @author Christian Tzolov
59+
*/
60+
@SpringBootTest
61+
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_PROJECT_ID", matches = ".*")
62+
@EnabledIfEnvironmentVariable(named = "VERTEX_AI_GEMINI_LOCATION", matches = ".*")
63+
public class VertexAiGeminiPaymentTransactionToolsIT {
64+
65+
private static final Logger logger = LoggerFactory.getLogger(VertexAiGeminiPaymentTransactionToolsIT.class);
66+
67+
private static final Map<Transaction, Status> DATASET = Map.of(new Transaction("001"), new Status("pending"),
68+
new Transaction("002"), new Status("approved"), new Transaction("003"), new Status("rejected"));
69+
70+
@Autowired
71+
ChatClient chatClient;
72+
73+
@Test
74+
public void paymentStatuses() {
75+
// @formatter:off
76+
String content = this.chatClient.prompt()
77+
.advisors(new LoggingAdvisor())
78+
.tools(new MyTools())
79+
.user("""
80+
What is the status of my payment transactions 001, 002 and 003?
81+
If requred invoke the function per transaction.
82+
""").call().content();
83+
// @formatter:on
84+
logger.info("" + content);
85+
86+
assertThat(content).contains("001", "002", "003");
87+
assertThat(content).contains("pending", "approved", "rejected");
88+
}
89+
90+
@RepeatedTest(5)
91+
public void streamingPaymentStatuses() {
92+
93+
Flux<String> streamContent = this.chatClient.prompt()
94+
.advisors(new LoggingAdvisor())
95+
.tools(new MyTools())
96+
.user("""
97+
What is the status of my payment transactions 001, 002 and 003?
98+
If requred invoke the function per transaction.
99+
""")
100+
.stream()
101+
.content();
102+
103+
String content = streamContent.collectList().block().stream().collect(Collectors.joining());
104+
105+
logger.info(content);
106+
107+
assertThat(content).contains("001", "002", "003");
108+
assertThat(content).contains("pending", "approved", "rejected");
109+
110+
// Quota rate
111+
try {
112+
Thread.sleep(1000);
113+
}
114+
catch (InterruptedException e) {
115+
}
116+
}
117+
118+
record TransactionStatusResponse(String id, String status) {
119+
120+
}
121+
122+
private static class LoggingAdvisor implements CallAroundAdvisor {
123+
124+
private final Logger logger = LoggerFactory.getLogger(LoggingAdvisor.class);
125+
126+
@Override
127+
public String getName() {
128+
return this.getClass().getSimpleName();
129+
}
130+
131+
@Override
132+
public int getOrder() {
133+
return 0;
134+
}
135+
136+
@Override
137+
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {
138+
var response = chain.nextAroundCall(before(advisedRequest));
139+
observeAfter(response);
140+
return response;
141+
}
142+
143+
private AdvisedRequest before(AdvisedRequest request) {
144+
logger.info("System text: \n" + request.systemText());
145+
logger.info("System params: " + request.systemParams());
146+
logger.info("User text: \n" + request.userText());
147+
logger.info("User params:" + request.userParams());
148+
logger.info("Function names: " + request.functionNames());
149+
150+
logger.info("Options: " + request.chatOptions().toString());
151+
152+
return request;
153+
}
154+
155+
private void observeAfter(AdvisedResponse advisedResponse) {
156+
logger.info("Response: " + advisedResponse.response());
157+
}
158+
159+
}
160+
161+
record Transaction(String id) {
162+
}
163+
164+
record Status(String name) {
165+
}
166+
167+
record Transactions(List<Transaction> transactions) {
168+
}
169+
170+
record Statuses(List<Status> statuses) {
171+
}
172+
173+
public static class MyTools {
174+
175+
@Tool(description = "Get the list statuses of a list of payment transactions")
176+
public Statuses paymentStatuses(Transactions transactions) {
177+
logger.info("Transactions: " + transactions);
178+
return new Statuses(transactions.transactions().stream().map(t -> DATASET.get(t)).toList());
179+
}
180+
181+
}
182+
183+
@SpringBootConfiguration
184+
public static class TestConfiguration {
185+
186+
@Bean
187+
public ChatClient chatClient(VertexAiGeminiChatModel chatModel) {
188+
return ChatClient.builder(chatModel).build();
189+
}
190+
191+
@Bean
192+
public VertexAI vertexAiApi() {
193+
194+
String projectId = System.getenv("VERTEX_AI_GEMINI_PROJECT_ID");
195+
String location = System.getenv("VERTEX_AI_GEMINI_LOCATION");
196+
197+
return new VertexAI.Builder().setLocation(location)
198+
.setProjectId(projectId)
199+
.setTransport(Transport.REST)
200+
// .setTransport(Transport.GRPC)
201+
.build();
202+
}
203+
204+
@Bean
205+
public VertexAiGeminiChatModel vertexAiChatModel(VertexAI vertexAi, ToolCallingManager toolCallingManager) {
206+
207+
return VertexAiGeminiChatModel.builder()
208+
.vertexAI(vertexAi)
209+
.toolCallingManager(toolCallingManager)
210+
.defaultOptions(VertexAiGeminiChatOptions.builder()
211+
.model(VertexAiGeminiChatModel.ChatModel.GEMINI_2_0_FLASH)
212+
.temperature(0.1)
213+
.build())
214+
.build();
215+
}
216+
217+
@Bean
218+
ToolCallingManager toolCallingManager(GenericApplicationContext applicationContext,
219+
List<FunctionCallback> toolCallbacks, ObjectProvider<ObservationRegistry> observationRegistry) {
220+
221+
var staticToolCallbackResolver = new StaticToolCallbackResolver(toolCallbacks);
222+
var springBeanToolCallbackResolver = SpringBeanToolCallbackResolver.builder()
223+
.applicationContext(applicationContext)
224+
.build();
225+
226+
ToolCallbackResolver toolCallbackResolver = new DelegatingToolCallbackResolver(
227+
List.of(staticToolCallbackResolver, springBeanToolCallbackResolver));
228+
229+
return ToolCallingManager.builder()
230+
.observationRegistry(observationRegistry.getIfUnique(() -> ObservationRegistry.NOOP))
231+
.toolCallbackResolver(toolCallbackResolver)
232+
.toolExecutionExceptionProcessor(new DefaultToolExecutionExceptionProcessor(false))
233+
.build();
234+
}
235+
236+
}
237+
238+
}

0 commit comments

Comments
 (0)