Skip to content

Commit 8d0caf4

Browse files
huidongyinilayaperumalg
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 <huidong.yin247203@gmail.com>
1 parent f2274eb commit 8d0caf4

File tree

4 files changed

+192
-11
lines changed

4 files changed

+192
-11
lines changed

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

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,21 @@
1616

1717
package org.springframework.ai.mcp;
1818

19+
import java.util.Map;
20+
1921
import io.modelcontextprotocol.client.McpAsyncClient;
20-
import io.modelcontextprotocol.spec.McpSchema;
2122
import io.modelcontextprotocol.spec.McpSchema.CallToolRequest;
2223
import io.modelcontextprotocol.spec.McpSchema.Tool;
23-
import java.util.Map;
24-
import reactor.core.publisher.Mono;
24+
import org.slf4j.Logger;
25+
import org.slf4j.LoggerFactory;
2526

2627
import org.springframework.ai.chat.model.ToolContext;
2728
import org.springframework.ai.model.ModelOptionsUtils;
2829
import org.springframework.ai.tool.ToolCallback;
2930
import org.springframework.ai.tool.definition.DefaultToolDefinition;
3031
import org.springframework.ai.tool.definition.ToolDefinition;
3132
import org.springframework.ai.tool.execution.ToolExecutionException;
33+
import org.springframework.util.StringUtils;
3234

3335
/**
3436
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
@@ -62,6 +64,8 @@
6264
*/
6365
public class AsyncMcpToolCallback implements ToolCallback {
6466

67+
private static final Logger logger = LoggerFactory.getLogger(AsyncMcpToolCallback.class);
68+
6569
private final McpAsyncClient asyncMcpClient;
6670

6771
private final Tool tool;
@@ -105,12 +109,19 @@ public ToolDefinition getToolDefinition() {
105109
* <li>Calls the tool through the MCP client asynchronously</li>
106110
* <li>Converts the tool's response content to a JSON string</li>
107111
* </ol>
108-
* @param functionInput the tool input as a JSON string
112+
* @param toolCallInput the tool input as a JSON string
109113
* @return the tool's response as a JSON string
110114
*/
111115
@Override
112-
public String call(String functionInput) {
113-
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
116+
public String call(String toolCallInput) {
117+
// Handle the possible null parameter situation in streaming mode.
118+
if (!StringUtils.hasText(toolCallInput)) {
119+
logger.warn("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.",
120+
this.tool.name());
121+
toolCallInput = "{}";
122+
}
123+
124+
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(toolCallInput);
114125
// Note that we use the original tool name here, not the adapted one from
115126
// getToolDefinition
116127
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
@@ -30,6 +30,7 @@
3030
import org.springframework.ai.tool.definition.DefaultToolDefinition;
3131
import org.springframework.ai.tool.definition.ToolDefinition;
3232
import org.springframework.ai.tool.execution.ToolExecutionException;
33+
import org.springframework.util.StringUtils;
3334

3435
/**
3536
* Implementation of {@link ToolCallback} that adapts MCP tools to Spring AI's tool
@@ -109,12 +110,19 @@ public ToolDefinition getToolDefinition() {
109110
* <li>Calls the tool through the MCP client</li>
110111
* <li>Converts the tool's response content to a JSON string</li>
111112
* </ol>
112-
* @param functionInput the tool input as a JSON string
113+
* @param toolCallInput the tool input as a JSON string
113114
* @return the tool's response as a JSON string
114115
*/
115116
@Override
116-
public String call(String functionInput) {
117-
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(functionInput);
117+
public String call(String toolCallInput) {
118+
// Handle the possible null parameter situation in streaming mode.
119+
if (!StringUtils.hasText(toolCallInput)) {
120+
logger.warn("Tool call arguments are null or empty for MCP tool: {}. Using empty JSON object as default.",
121+
this.tool.name());
122+
toolCallInput = "{}";
123+
}
124+
125+
Map<String, Object> arguments = ModelOptionsUtils.jsonToMap(toolCallInput);
118126

119127
CallToolResult response;
120128
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)