Skip to content

Commit 1df2af3

Browse files
Validate inputSchema for MCP tool definitions (#4855)
- For both Sync/Async toolcallbacks, verify the inputSchema when setting the Spring AI ToolDefinition - This is needed for the cases where the MCP servers that provide Tools with incomplete inputSchema. In particular schemas that doesn't include JSON schema parameters such as "properties" when parameterless tool definition is used. This specific case is implicitly handled for Spring AI's FunctionToolCallback, MethodToolCallback (@tool) and @mcptool tools when creating MCP Servers. The fix is only needs for external (non SpringAI) MCP server that doesn't provide the required JSON fields. - Applied the fix specifically for Sync and Async ToolCallbacks - Added JsonSchemaUtils to ensure the schema is validated and fixed with the required parameters. Thanks to @liugddx for the fix from fix: ensure valid parameters schema for parameter-less functions in OpenAiApi #4832 - Add integration tests in OpenAiChatModelIT to verify MCP tool callbacks with incorrect inputSchema is handled correctly - Add MCP integration test to validate the same - Move Sync/Async ToolDefinition creation into utils - Add assertion and updates to McpToolCallbackParameterlessToolIT Fixes #4776 Signed-off-by: Ilayaperumal Gopinathan <[email protected]> Co-authored-by: liugddx <[email protected]>
1 parent 2768b4c commit 1df2af3

File tree

8 files changed

+607
-12
lines changed

8 files changed

+607
-12
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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.mcp.server.autoconfigure;
18+
19+
import java.time.Duration;
20+
import java.time.Instant;
21+
import java.util.Arrays;
22+
import java.util.List;
23+
import java.util.Map;
24+
import java.util.stream.Stream;
25+
26+
import com.fasterxml.jackson.databind.ObjectMapper;
27+
import io.modelcontextprotocol.server.McpServerFeatures;
28+
import io.modelcontextprotocol.server.McpSyncServer;
29+
import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider;
30+
import io.modelcontextprotocol.spec.McpSchema;
31+
import org.junit.jupiter.api.Test;
32+
import reactor.netty.DisposableServer;
33+
import reactor.netty.http.server.HttpServer;
34+
35+
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
36+
import org.springframework.ai.mcp.client.common.autoconfigure.McpClientAutoConfiguration;
37+
import org.springframework.ai.mcp.client.common.autoconfigure.McpToolCallbackAutoConfiguration;
38+
import org.springframework.ai.mcp.client.common.autoconfigure.annotations.McpClientAnnotationScannerAutoConfiguration;
39+
import org.springframework.ai.mcp.client.webflux.autoconfigure.StreamableHttpWebFluxTransportAutoConfiguration;
40+
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerAutoConfiguration;
41+
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerObjectMapperAutoConfiguration;
42+
import org.springframework.ai.mcp.server.common.autoconfigure.ToolCallbackConverterAutoConfiguration;
43+
import org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerAnnotationScannerAutoConfiguration;
44+
import org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerSpecificationFactoryAutoConfiguration;
45+
import org.springframework.ai.model.ModelOptionsUtils;
46+
import org.springframework.ai.retry.autoconfigure.SpringAiRetryAutoConfiguration;
47+
import org.springframework.ai.tool.ToolCallback;
48+
import org.springframework.ai.tool.ToolCallbackProvider;
49+
import org.springframework.ai.tool.definition.ToolDefinition;
50+
import org.springframework.boot.autoconfigure.AutoConfigurations;
51+
import org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration;
52+
import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration;
53+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
54+
import org.springframework.context.ApplicationContext;
55+
import org.springframework.http.server.reactive.HttpHandler;
56+
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
57+
import org.springframework.test.util.TestSocketUtils;
58+
import org.springframework.web.reactive.function.server.RouterFunctions;
59+
60+
import static org.assertj.core.api.Assertions.assertThat;
61+
import static org.awaitility.Awaitility.await;
62+
63+
/**
64+
* Integration test to reproduce the issue where MCP tools with no parameters (incomplete
65+
* schemas) fail to create valid tool definitions.
66+
*
67+
* @author Ilayaperumal Gopinathan
68+
*/
69+
class McpToolCallbackParameterlessToolIT {
70+
71+
private final ApplicationContextRunner syncServerContextRunner = new ApplicationContextRunner()
72+
.withPropertyValues("spring.ai.mcp.server.protocol=STREAMABLE", "spring.ai.mcp.server.type=SYNC")
73+
.withConfiguration(AutoConfigurations.of(McpServerAutoConfiguration.class,
74+
McpServerObjectMapperAutoConfiguration.class, ToolCallbackConverterAutoConfiguration.class,
75+
McpServerStreamableHttpWebFluxAutoConfiguration.class,
76+
McpServerAnnotationScannerAutoConfiguration.class,
77+
McpServerSpecificationFactoryAutoConfiguration.class));
78+
79+
private final ApplicationContextRunner clientApplicationContext = new ApplicationContextRunner()
80+
.withConfiguration(baseAutoConfig(McpToolCallbackAutoConfiguration.class, McpClientAutoConfiguration.class,
81+
StreamableHttpWebFluxTransportAutoConfiguration.class,
82+
McpClientAnnotationScannerAutoConfiguration.class));
83+
84+
private static AutoConfigurations baseAutoConfig(Class<?>... additional) {
85+
Class<?>[] dependencies = { SpringAiRetryAutoConfiguration.class, RestClientAutoConfiguration.class,
86+
WebClientAutoConfiguration.class };
87+
Class<?>[] all = Stream.concat(Arrays.stream(dependencies), Arrays.stream(additional)).toArray(Class<?>[]::new);
88+
return AutoConfigurations.of(all);
89+
}
90+
91+
@Test
92+
void testMcpServerClientIntegrationWithIncompleteSchemaSyncTool() {
93+
int serverPort = TestSocketUtils.findAvailableTcpPort();
94+
95+
this.syncServerContextRunner
96+
.withPropertyValues(// @formatter:off
97+
"spring.ai.mcp.server.name=test-incomplete-schema-server",
98+
"spring.ai.mcp.server.version=1.0.0",
99+
"spring.ai.mcp.server.streamable-http.keep-alive-interval=1s",
100+
"spring.ai.mcp.server.streamable-http.mcp-endpoint=/mcp") // @formatter:on
101+
.run(serverContext -> {
102+
103+
McpSyncServer mcpSyncServer = serverContext.getBean(McpSyncServer.class);
104+
105+
ObjectMapper objectMapper = serverContext.getBean(ObjectMapper.class);
106+
107+
String incompleteSchemaJson = "{\"type\":\"object\",\"additionalProperties\":false}";
108+
McpSchema.JsonSchema incompleteSchema = objectMapper.readValue(incompleteSchemaJson,
109+
McpSchema.JsonSchema.class);
110+
111+
// Build the tool using the builder pattern
112+
McpSchema.Tool parameterlessTool = McpSchema.Tool.builder()
113+
.name("getCurrentTime")
114+
.description("Get the current server time")
115+
.inputSchema(incompleteSchema)
116+
.build();
117+
118+
// Create a tool specification that returns a simple response
119+
McpServerFeatures.SyncToolSpecification toolSpec = new McpServerFeatures.SyncToolSpecification(
120+
parameterlessTool, (exchange, arguments) -> {
121+
McpSchema.TextContent content = new McpSchema.TextContent(
122+
"Current time: " + Instant.now().toString());
123+
return new McpSchema.CallToolResult(List.of(content), false, null);
124+
}, (exchange, request) -> {
125+
McpSchema.TextContent content = new McpSchema.TextContent(
126+
"Current time: " + Instant.now().toString());
127+
return new McpSchema.CallToolResult(List.of(content), false, null);
128+
});
129+
130+
// Add the tool with incomplete schema to the server
131+
mcpSyncServer.addTool(toolSpec);
132+
133+
var httpServer = startHttpServer(serverContext, serverPort);
134+
135+
this.clientApplicationContext
136+
.withPropertyValues(// @formatter:off
137+
"spring.ai.mcp.client.type=SYNC",
138+
"spring.ai.mcp.client.streamable-http.connections.server1.url=http://localhost:" + serverPort,
139+
"spring.ai.mcp.client.initialized=false") // @formatter:on
140+
.run(clientContext -> {
141+
142+
ToolCallbackProvider toolCallbackProvider = clientContext
143+
.getBean(SyncMcpToolCallbackProvider.class);
144+
145+
// Wait for the client to receive the tool from the server
146+
await().atMost(Duration.ofSeconds(5))
147+
.pollInterval(Duration.ofMillis(100))
148+
.untilAsserted(() -> assertThat(toolCallbackProvider.getToolCallbacks()).isNotEmpty());
149+
150+
List<ToolCallback> toolCallbacks = Arrays.asList(toolCallbackProvider.getToolCallbacks());
151+
152+
// We expect 1 tool: getCurrentTime (parameterless with incomplete
153+
// schema)
154+
assertThat(toolCallbacks).hasSize(1);
155+
156+
// Get the tool callback
157+
ToolCallback toolCallback = toolCallbacks.get(0);
158+
ToolDefinition toolDefinition = toolCallback.getToolDefinition();
159+
160+
// Verify the tool definition
161+
assertThat(toolDefinition).isNotNull();
162+
assertThat(toolDefinition.name()).contains("getCurrentTime");
163+
assertThat(toolDefinition.description()).isEqualTo("Get the current server time");
164+
165+
// **THE KEY VERIFICATION**: The input schema should now have the
166+
// "properties" field
167+
// even though the server provided a schema without it
168+
String inputSchema = toolDefinition.inputSchema();
169+
assertThat(inputSchema).isNotNull().isNotEmpty();
170+
171+
Map<String, Object> schemaMap = ModelOptionsUtils.jsonToMap(inputSchema);
172+
assertThat(schemaMap).isNotNull();
173+
assertThat(schemaMap).containsKey("type");
174+
assertThat(schemaMap.get("type")).isEqualTo("object");
175+
176+
assertThat(schemaMap).containsKey("properties");
177+
assertThat(schemaMap.get("properties")).isInstanceOf(Map.class);
178+
179+
// Verify the properties map is empty for a parameterless tool
180+
Map<String, Object> properties = (Map<String, Object>) schemaMap.get("properties");
181+
assertThat(properties).isEmpty();
182+
183+
// Verify that additionalProperties is preserved after
184+
// normalization
185+
assertThat(schemaMap).containsKey("additionalProperties");
186+
assertThat(schemaMap.get("additionalProperties")).isEqualTo(false);
187+
188+
// Test that the callback can be called successfully
189+
String result = toolCallback.call("{}");
190+
assertThat(result).isNotNull().contains("Current time:");
191+
});
192+
193+
stopHttpServer(httpServer);
194+
});
195+
}
196+
197+
// Helper methods to start and stop the HTTP server
198+
private static DisposableServer startHttpServer(ApplicationContext serverContext, int port) {
199+
WebFluxStreamableServerTransportProvider mcpStreamableServerTransport = serverContext
200+
.getBean(WebFluxStreamableServerTransportProvider.class);
201+
HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpStreamableServerTransport.getRouterFunction());
202+
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
203+
return HttpServer.create().port(port).handle(adapter).bindNow();
204+
}
205+
206+
private static void stopHttpServer(DisposableServer server) {
207+
if (server != null) {
208+
server.disposeNow();
209+
}
210+
}
211+
212+
}

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
import org.springframework.ai.model.ModelOptionsUtils;
3131
import org.springframework.ai.model.tool.internal.ToolCallReactiveContextHolder;
3232
import org.springframework.ai.tool.ToolCallback;
33-
import org.springframework.ai.tool.definition.DefaultToolDefinition;
3433
import org.springframework.ai.tool.definition.ToolDefinition;
3534
import org.springframework.ai.tool.execution.ToolExecutionException;
3635
import org.springframework.lang.Nullable;
@@ -45,6 +44,7 @@
4544
*
4645
* @author Christian Tzolov
4746
* @author YunKui Lu
47+
* @author Ilayaperumal Gopinathan
4848
*/
4949
public class AsyncMcpToolCallback implements ToolCallback {
5050

@@ -92,11 +92,7 @@ private AsyncMcpToolCallback(McpAsyncClient mcpClient, Tool tool, String prefixe
9292

9393
@Override
9494
public ToolDefinition getToolDefinition() {
95-
return DefaultToolDefinition.builder()
96-
.name(this.prefixedToolName)
97-
.description(this.tool.description())
98-
.inputSchema(ModelOptionsUtils.toJsonString(this.tool.inputSchema()))
99-
.build();
95+
return McpToolUtils.createToolDefinition(this.prefixedToolName, this.tool);
10096
}
10197

10298
public String getOriginalToolName() {

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
import org.springframework.ai.chat.model.ToolContext;
4040
import org.springframework.ai.model.ModelOptionsUtils;
4141
import org.springframework.ai.tool.ToolCallback;
42+
import org.springframework.ai.tool.definition.DefaultToolDefinition;
43+
import org.springframework.ai.tool.definition.ToolDefinition;
44+
import org.springframework.ai.util.json.schema.JsonSchemaUtils;
4245
import org.springframework.lang.Nullable;
4346
import org.springframework.util.CollectionUtils;
4447
import org.springframework.util.MimeType;
@@ -62,6 +65,7 @@
6265
* </ul>
6366
*
6467
* @author Christian Tzolov
68+
* @author Ilayaperumal Gopinathan
6569
*/
6670
public final class McpToolUtils {
6771

@@ -227,6 +231,20 @@ public static McpStatelessServerFeatures.SyncToolSpecification toStatelessSyncTo
227231
.build();
228232
}
229233

234+
/**
235+
* Creates a Spring AI ToolDefinition from an MCP Tool.
236+
* @param prefixedToolName the prefixed name for the tool
237+
* @param tool the MCP tool
238+
* @return a ToolDefinition with normalized input schema
239+
*/
240+
public static ToolDefinition createToolDefinition(String prefixedToolName, McpSchema.Tool tool) {
241+
return DefaultToolDefinition.builder()
242+
.name(prefixedToolName)
243+
.description(tool.description())
244+
.inputSchema(JsonSchemaUtils.ensureValidInputSchema(ModelOptionsUtils.toJsonString(tool.inputSchema())))
245+
.build();
246+
}
247+
230248
private static SharedSyncToolSpecification toSharedSyncToolSpecification(ToolCallback toolCallback,
231249
MimeType mimeType) {
232250

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import org.springframework.ai.chat.model.ToolContext;
3030
import org.springframework.ai.model.ModelOptionsUtils;
3131
import org.springframework.ai.tool.ToolCallback;
32-
import org.springframework.ai.tool.definition.DefaultToolDefinition;
3332
import org.springframework.ai.tool.definition.ToolDefinition;
3433
import org.springframework.ai.tool.execution.ToolExecutionException;
3534
import org.springframework.lang.Nullable;
@@ -41,6 +40,7 @@
4140
*
4241
* @author Christian Tzolov
4342
* @author YunKui Lu
43+
* @author Ilayaperumal Gopinathan
4444
* @since 1.0.0
4545
*/
4646
public class SyncMcpToolCallback implements ToolCallback {
@@ -89,11 +89,7 @@ private SyncMcpToolCallback(McpSyncClient mcpClient, Tool tool, String prefixedT
8989

9090
@Override
9191
public ToolDefinition getToolDefinition() {
92-
return DefaultToolDefinition.builder()
93-
.name(this.prefixedToolName)
94-
.description(this.tool.description())
95-
.inputSchema(ModelOptionsUtils.toJsonString(this.tool.inputSchema()))
96-
.build();
92+
return McpToolUtils.createToolDefinition(this.prefixedToolName, this.tool);
9793
}
9894

9995
/**

models/spring-ai-openai/pom.xml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,19 @@
8787
<scope>test</scope>
8888
</dependency>
8989

90+
<dependency>
91+
<groupId>org.springframework.ai</groupId>
92+
<artifactId>spring-ai-mcp</artifactId>
93+
<version>${project.version}</version>
94+
<scope>test</scope>
95+
</dependency>
96+
97+
<dependency>
98+
<groupId>org.mockito</groupId>
99+
<artifactId>mockito-core</artifactId>
100+
<scope>test</scope>
101+
</dependency>
102+
90103
<dependency>
91104
<groupId>io.micrometer</groupId>
92105
<artifactId>micrometer-observation-test</artifactId>

0 commit comments

Comments
 (0)