Skip to content

Commit d2706be

Browse files
huidongyinchedim
authored andcommitted
fix: Handle null/empty tool call arguments in streaming mode
- Add null/empty argument validation in AsyncMcpToolCallback and SyncMcpToolCallback - Add null/empty argument handling in DefaultToolCallingManager - Default to empty JSON object "{}" when tool arguments are null or empty - Add warning logs when encountering null/empty arguments - Add unit tests to verify null/empty argument handling This prevents potential NPE and JSON parsing errors when tool calls have missing arguments, particularly in streaming mode scenarios. Signed-off-by: huidong.yin <[email protected]>
1 parent f9cdd8e commit d2706be

File tree

4 files changed

+190
-8
lines changed

4 files changed

+190
-8
lines changed

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
import io.modelcontextprotocol.client.McpAsyncClient;
2222
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
2323
import io.modelcontextprotocol.spec.McpSchema.Tool;
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
2426

2527
import org.springframework.ai.chat.model.ToolContext;
2628
import org.springframework.ai.model.ModelOptionsUtils;
@@ -29,6 +31,7 @@
2931
import org.springframework.ai.tool.definition.DefaultToolDefinition;
3032
import org.springframework.ai.tool.definition.ToolDefinition;
3133
import org.springframework.ai.tool.execution.ToolExecutionException;
34+
import org.springframework.util.StringUtils;
3235

3336
/**
3437
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
@@ -62,6 +65,8 @@
6265
*/
6366
public class AsyncMcpToolCallback implements ToolCallback {
6467

68+
private static final Logger logger = LoggerFactory.getLogger(AsyncMcpToolCallback.class);
69+
6570
private final McpAsyncClient asyncMcpClient;
6671

6772
private final Tool tool;
@@ -109,12 +114,19 @@ public String getOriginalToolName() {
109114
* <li>Calls the tool through the MCP client asynchronously</li>
110115
* <li>Converts the tool's response content to a JSON string</li>
111116
* </ol>
112-
* @param functionInput the tool input as a JSON string
117+
* @param toolCallInput the tool input as a JSON string
113118
* @return the tool's response as a JSON string
114119
*/
115120
@Override
116-
public String call(String functionInput) {
117-
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
121+
public String call(String toolCallInput) {
122+
// Handle the possible null parameter situation in streaming mode.
123+
if (!StringUtils.hasText(toolCallInput)) {
124+
logger.warn("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.",
125+
this.tool.name());
126+
toolCallInput = "{}";
127+
}
128+
129+
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(toolCallInput);
118130
// Note that we use the original tool name here, not the adapted one from
119131
// getToolDefinition
120132
return this.asyncMcpClient.callTool(new CallToolRequest(this.tool.name(), arguments)).onErrorMap(exception -> {

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.springframework.ai.tool.definition.DefaultToolDefinition;
3232
import org.springframework.ai.tool.definition.ToolDefinition;
3333
import org.springframework.ai.tool.execution.ToolExecutionException;
34+
import org.springframework.util.StringUtils;
3435

3536
/**
3637
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
@@ -114,12 +115,19 @@ public String getOriginalToolName() {
114115
* <li>Calls the tool through the MCP client</li>
115116
* <li>Converts the tool's response content to a JSON string</li>
116117
* </ol>
117-
* @param functionInput the tool input as a JSON string
118+
* @param toolCallInput the tool input as a JSON string
118119
* @return the tool's response as a JSON string
119120
*/
120121
@Override
121-
public String call(String functionInput) {
122-
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
122+
public String call(String toolCallInput) {
123+
// Handle the possible null parameter situation in streaming mode.
124+
if (!StringUtils.hasText(toolCallInput)) {
125+
logger.warn("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.",
126+
this.tool.name());
127+
toolCallInput = "{}";
128+
}
129+
130+
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(toolCallInput);
123131

124132
CallToolResult response;
125133
try {

spring-ai-model/src/main/java/org/springframework/ai/model/tool/DefaultToolCallingManager.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
import org.springframework.ai.tool.resolution.ToolCallbackResolver;
4747
import org.springframework.util.Assert;
4848
import org.springframework.util.CollectionUtils;
49+
import org.springframework.util.StringUtils;
4950

5051
/**
5152
* Default implementation of {@link ToolCallingManager}.
@@ -189,6 +190,17 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess
189190
String toolName = toolCall.name();
190191
String toolInputArguments = toolCall.arguments();
191192

193+
// Handle the possible null parameter situation in streaming mode.
194+
final String finalToolInputArguments;
195+
if (!StringUtils.hasText(toolInputArguments)) {
196+
logger.warn("Tool call arguments are null or empty for tool: {}. Using empty JSON object as default.",
197+
toolName);
198+
finalToolInputArguments = "{}";
199+
}
200+
else {
201+
finalToolInputArguments = toolInputArguments;
202+
}
203+
192204
ToolCallback toolCallback = toolCallbacks.stream()
193205
.filter(tool -> toolName.equals(tool.getToolDefinition().name()))
194206
.findFirst()
@@ -208,7 +220,7 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess
208220
ToolCallingObservationContext observationContext = ToolCallingObservationContext.builder()
209221
.toolDefinition(toolCallback.getToolDefinition())
210222
.toolMetadata(toolCallback.getToolMetadata())
211-
.toolCallArguments(toolInputArguments)
223+
.toolCallArguments(finalToolInputArguments)
212224
.build();
213225

214226
String toolCallResult = ToolCallingObservationDocumentation.TOOL_CALL
@@ -217,7 +229,7 @@ private InternalToolExecutionResult executeToolCall(Prompt prompt, AssistantMess
217229
.observe(() -> {
218230
String toolResult;
219231
try {
220-
toolResult = toolCallback.call(toolInputArguments, toolContext);
232+
toolResult = toolCallback.call(finalToolInputArguments, toolContext);
221233
}
222234
catch (ToolExecutionException ex) {
223235
toolResult = this.toolExecutionExceptionProcessor.process(ex);
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright 2023-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.model.tool;
18+
19+
import java.util.List;
20+
import java.util.Map;
21+
22+
import io.micrometer.observation.ObservationRegistry;
23+
import org.junit.jupiter.api.Test;
24+
25+
import org.springframework.ai.chat.messages.AssistantMessage;
26+
import org.springframework.ai.chat.messages.UserMessage;
27+
import org.springframework.ai.chat.model.ChatResponse;
28+
import org.springframework.ai.chat.model.Generation;
29+
import org.springframework.ai.chat.prompt.Prompt;
30+
import org.springframework.ai.tool.ToolCallback;
31+
import org.springframework.ai.tool.definition.DefaultToolDefinition;
32+
import org.springframework.ai.tool.definition.ToolDefinition;
33+
import org.springframework.ai.tool.metadata.ToolMetadata;
34+
35+
import static org.assertj.core.api.Assertions.assertThat;
36+
import static org.assertj.core.api.Assertions.assertThatNoException;
37+
38+
/**
39+
* Tests for {@link DefaultToolCallingManager} with empty/null arguments handling.
40+
*
41+
*/
42+
class DefaultToolCallingManagerTest {
43+
44+
@Test
45+
void shouldHandleNullArgumentsInStreamMode() {
46+
// Create a mock tool callback
47+
ToolCallback mockToolCallback = new ToolCallback() {
48+
@Override
49+
public ToolDefinition getToolDefinition() {
50+
return DefaultToolDefinition.builder()
51+
.name("testTool")
52+
.description("A test tool")
53+
.inputSchema("{}")
54+
.build();
55+
}
56+
57+
@Override
58+
public ToolMetadata getToolMetadata() {
59+
return ToolMetadata.builder().build();
60+
}
61+
62+
@Override
63+
public String call(String toolInput) {
64+
// Verify the input is not null or empty
65+
assertThat(toolInput).isNotNull();
66+
assertThat(toolInput).isNotEmpty();
67+
return "{\"result\": \"success\"}";
68+
}
69+
};
70+
71+
// Create a ToolCall with empty parameters
72+
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("1", "function", "testTool", null);
73+
74+
// Create a ChatResponse
75+
AssistantMessage assistantMessage = new AssistantMessage("", Map.of(), List.of(toolCall));
76+
Generation generation = new Generation(assistantMessage);
77+
ChatResponse chatResponse = new ChatResponse(List.of(generation));
78+
79+
// Create a Prompt with tool callbacks
80+
Prompt prompt = new Prompt(List.of(new UserMessage("test")));
81+
82+
// Mock the tool callbacks resolution by creating a custom ToolCallbackResolver
83+
DefaultToolCallingManager managerWithCallback = DefaultToolCallingManager.builder()
84+
.observationRegistry(ObservationRegistry.NOOP)
85+
.toolCallbackResolver(toolName -> {
86+
if ("testTool".equals(toolName)) {
87+
return mockToolCallback;
88+
}
89+
return null;
90+
})
91+
.build();
92+
93+
// Verify that no exception is thrown
94+
assertThatNoException().isThrownBy(() -> managerWithCallback.executeToolCalls(prompt, chatResponse));
95+
}
96+
97+
@Test
98+
void shouldHandleEmptyArgumentsInStreamMode() {
99+
// Create a mock tool callback
100+
ToolCallback mockToolCallback = new ToolCallback() {
101+
@Override
102+
public ToolDefinition getToolDefinition() {
103+
return DefaultToolDefinition.builder()
104+
.name("testTool")
105+
.description("A test tool")
106+
.inputSchema("{}")
107+
.build();
108+
}
109+
110+
@Override
111+
public ToolMetadata getToolMetadata() {
112+
return ToolMetadata.builder().build();
113+
}
114+
115+
@Override
116+
public String call(String toolInput) {
117+
// Verify the input is not null or empty
118+
assertThat(toolInput).isNotNull();
119+
assertThat(toolInput).isNotEmpty();
120+
return "{\"result\": \"success\"}";
121+
}
122+
};
123+
124+
// Create a ToolCall with empty parameters
125+
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("1", "function", "testTool", "");
126+
127+
// Create a ChatResponse
128+
AssistantMessage assistantMessage = new AssistantMessage("", Map.of(), List.of(toolCall));
129+
Generation generation = new Generation(assistantMessage);
130+
ChatResponse chatResponse = new ChatResponse(List.of(generation));
131+
132+
// Create a Prompt with tool callbacks
133+
Prompt prompt = new Prompt(List.of(new UserMessage("test")));
134+
135+
// Mock the tool callbacks resolution by creating a custom ToolCallbackResolver
136+
DefaultToolCallingManager managerWithCallback = DefaultToolCallingManager.builder()
137+
.observationRegistry(ObservationRegistry.NOOP)
138+
.toolCallbackResolver(toolName -> {
139+
if ("testTool".equals(toolName)) {
140+
return mockToolCallback;
141+
}
142+
return null;
143+
})
144+
.build();
145+
146+
// Verify that no exception is thrown
147+
assertThatNoException().isThrownBy(() -> managerWithCallback.executeToolCalls(prompt, chatResponse));
148+
}
149+
150+
}

0 commit comments

Comments
 (0)