Skip to content

Commit 327cd93

Browse files
committed
feat: introduce McpServerObjectMapperFactory for consistent ObjectMapper configuration
Signed-off-by: liugddx <[email protected]>
1 parent 528e0b9 commit 327cd93

File tree

2 files changed

+333
-0
lines changed

2 files changed

+333
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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.common.autoconfigure;
18+
19+
import com.fasterxml.jackson.annotation.JsonInclude;
20+
import com.fasterxml.jackson.databind.DeserializationFeature;
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.fasterxml.jackson.databind.SerializationFeature;
23+
import com.fasterxml.jackson.databind.json.JsonMapper;
24+
25+
import org.springframework.ai.util.JacksonUtils;
26+
27+
/**
28+
* Factory class for creating properly configured {@link ObjectMapper} instances for MCP
29+
* server operations.
30+
* <p>
31+
* This factory ensures consistent JSON serialization/deserialization configuration across
32+
* all MCP server transport types (STDIO, SSE, Streamable-HTTP, Stateless). The
33+
* configuration is optimized for MCP protocol compliance and handles common edge cases
34+
* that can cause serialization failures.
35+
* <p>
36+
* Key configuration features:
37+
* <ul>
38+
* <li><b>Lenient Deserialization:</b> Does not fail on unknown JSON properties, allowing
39+
* forward compatibility</li>
40+
* <li><b>Empty Bean Handling:</b> Does not fail when serializing beans without
41+
* properties</li>
42+
* <li><b>Null Value Exclusion:</b> Excludes null values from JSON output for cleaner
43+
* messages</li>
44+
* <li><b>Date/Time Formatting:</b> Uses ISO-8601 format instead of timestamps</li>
45+
* <li><b>Jackson Modules:</b> Registers standard modules for Java 8, JSR-310, parameter
46+
* names, and Kotlin (if available)</li>
47+
* </ul>
48+
* <p>
49+
* This factory was introduced to fix Issue #4451 where @McpTool annotated methods failed
50+
* to load with STDIO protocol due to JSON serialization errors caused by using an
51+
* unconfigured ObjectMapper instance.
52+
*
53+
* @author Spring AI Team
54+
* @see <a href="https://github.com/spring-projects/spring-ai/issues/4451">Issue #4451</a>
55+
*/
56+
public final class McpServerObjectMapperFactory {
57+
58+
private McpServerObjectMapperFactory() {
59+
// Utility class - prevent instantiation
60+
}
61+
62+
/**
63+
* Creates a new {@link ObjectMapper} instance configured for MCP server operations.
64+
* <p>
65+
* This method creates a fresh ObjectMapper with standard configuration suitable for
66+
* MCP protocol serialization/deserialization. Each call creates a new instance, so
67+
* callers may want to cache the result if creating multiple instances.
68+
* @return a properly configured ObjectMapper instance
69+
*/
70+
public static ObjectMapper createObjectMapper() {
71+
return JsonMapper.builder()
72+
// Deserialization configuration
73+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
74+
.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT)
75+
// Serialization configuration
76+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
77+
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
78+
.serializationInclusion(JsonInclude.Include.NON_NULL)
79+
// Register standard Jackson modules (Jdk8, JavaTime, ParameterNames, Kotlin)
80+
.addModules(JacksonUtils.instantiateAvailableModules())
81+
.build();
82+
}
83+
84+
/**
85+
* Retrieves an ObjectMapper from the provided provider, or creates a configured
86+
* default if none is available.
87+
* <p>
88+
* This method is designed for use in Spring auto-configuration classes where an
89+
* ObjectMapper may optionally be provided by the user. If no ObjectMapper bean is
90+
* available, this method ensures a properly configured instance is used rather than a
91+
* vanilla ObjectMapper.
92+
* <p>
93+
* Example usage in auto-configuration:
94+
*
95+
* <pre>{@code
96+
* &#64;Bean
97+
* public TransportProvider transport(ObjectProvider<ObjectMapper> objectMapperProvider) {
98+
* ObjectMapper mapper = McpServerObjectMapperFactory.getOrCreateObjectMapper(objectMapperProvider);
99+
* return new TransportProvider(mapper);
100+
* }
101+
* }</pre>
102+
* @param objectMapperProvider the Spring ObjectProvider for ObjectMapper beans
103+
* @return the provided ObjectMapper, or a newly configured default instance
104+
*/
105+
public static ObjectMapper getOrCreateObjectMapper(
106+
org.springframework.beans.factory.ObjectProvider<ObjectMapper> objectMapperProvider) {
107+
return objectMapperProvider.getIfAvailable(McpServerObjectMapperFactory::createObjectMapper);
108+
}
109+
110+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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.common.autoconfigure;
18+
19+
import java.util.List;
20+
import java.util.concurrent.CopyOnWriteArrayList;
21+
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
import io.modelcontextprotocol.server.McpAsyncServer;
24+
import io.modelcontextprotocol.server.McpServerFeatures.AsyncToolSpecification;
25+
import io.modelcontextprotocol.server.McpSyncServer;
26+
import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
27+
import io.modelcontextprotocol.spec.McpSchema;
28+
import io.modelcontextprotocol.spec.McpServerTransportProviderBase;
29+
import org.junit.jupiter.api.Test;
30+
import org.springaicommunity.mcp.annotation.McpTool;
31+
import org.springaicommunity.mcp.annotation.McpToolParam;
32+
33+
import org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerAnnotationScannerAutoConfiguration;
34+
import org.springframework.ai.mcp.server.common.autoconfigure.annotations.McpServerSpecificationFactoryAutoConfiguration;
35+
import org.springframework.boot.autoconfigure.AutoConfigurations;
36+
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
37+
import org.springframework.stereotype.Component;
38+
import org.springframework.test.util.ReflectionTestUtils;
39+
40+
import static org.assertj.core.api.Assertions.assertThat;
41+
42+
/**
43+
* Integration tests for @McpTool annotations with STDIO transport.
44+
* <p>
45+
* This test verifies the fix for Issue #4451 where @McpTool annotated methods failed to
46+
* load when using STDIO protocol due to JSON serialization issues with unconfigured
47+
* ObjectMapper.
48+
*
49+
* @see <a href="https://github.com/spring-projects/spring-ai/issues/4451">Issue #4451</a>
50+
*/
51+
public class McpToolWithStdioIT {
52+
53+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration(
54+
AutoConfigurations.of(McpServerAutoConfiguration.class, McpServerAnnotationScannerAutoConfiguration.class,
55+
McpServerSpecificationFactoryAutoConfiguration.class));
56+
57+
/**
58+
* Verifies that a configured ObjectMapper bean is created for MCP server operations.
59+
*/
60+
@Test
61+
void shouldCreateConfiguredObjectMapperForMcpServer() {
62+
this.contextRunner.run(context -> {
63+
assertThat(context).hasSingleBean(ObjectMapper.class);
64+
ObjectMapper objectMapper = context.getBean("mcpServerObjectMapper", ObjectMapper.class);
65+
66+
assertThat(objectMapper).isNotNull();
67+
68+
// Verify that the ObjectMapper is properly configured
69+
String emptyBeanJson = objectMapper.writeValueAsString(new EmptyBean());
70+
assertThat(emptyBeanJson).isEqualTo("{}"); // Should not fail on empty beans
71+
72+
String nullValueJson = objectMapper.writeValueAsString(new BeanWithNull());
73+
assertThat(nullValueJson).doesNotContain("null"); // Should exclude null
74+
// values
75+
});
76+
}
77+
78+
/**
79+
* Verifies that STDIO transport uses the configured ObjectMapper.
80+
*/
81+
@Test
82+
void stdioTransportShouldUseConfiguredObjectMapper() {
83+
this.contextRunner.run(context -> {
84+
assertThat(context).hasSingleBean(McpServerTransportProviderBase.class);
85+
assertThat(context.getBean(McpServerTransportProviderBase.class))
86+
.isInstanceOf(StdioServerTransportProvider.class);
87+
88+
// Verify that the MCP server was created successfully
89+
assertThat(context).hasSingleBean(McpSyncServer.class);
90+
});
91+
}
92+
93+
/**
94+
* Verifies that @McpTool annotated methods are successfully registered with STDIO
95+
* transport.
96+
* <p>
97+
* This is the core test for Issue #4451 - it ensures that tool specifications
98+
* generated from @McpTool annotations can be properly serialized to JSON without
99+
* errors.
100+
*/
101+
@Test
102+
@SuppressWarnings("unchecked")
103+
void mcpToolAnnotationsShouldWorkWithStdio() {
104+
this.contextRunner.withBean(TestCalculatorTools.class).run(context -> {
105+
// Verify the server was created
106+
assertThat(context).hasSingleBean(McpSyncServer.class);
107+
McpSyncServer syncServer = context.getBean(McpSyncServer.class);
108+
109+
// Get the async server from sync server (internal structure)
110+
McpAsyncServer asyncServer = (McpAsyncServer) ReflectionTestUtils.getField(syncServer, "asyncServer");
111+
assertThat(asyncServer).isNotNull();
112+
113+
// Verify that tools were registered
114+
CopyOnWriteArrayList<AsyncToolSpecification> tools = (CopyOnWriteArrayList<AsyncToolSpecification>) ReflectionTestUtils
115+
.getField(asyncServer, "tools");
116+
117+
assertThat(tools).isNotEmpty();
118+
assertThat(tools).hasSize(3);
119+
120+
// Verify tool names
121+
List<String> toolNames = tools.stream().map(spec -> spec.tool().name()).toList();
122+
assertThat(toolNames).containsExactlyInAnyOrder("add", "subtract", "multiply");
123+
124+
// Verify that each tool has a valid inputSchema that can be serialized
125+
ObjectMapper objectMapper = context.getBean("mcpServerObjectMapper", ObjectMapper.class);
126+
127+
for (AsyncToolSpecification spec : tools) {
128+
McpSchema.Tool tool = spec.tool();
129+
130+
// Verify basic tool properties
131+
assertThat(tool.name()).isNotBlank();
132+
assertThat(tool.description()).isNotBlank();
133+
134+
// Verify inputSchema can be serialized to JSON without errors
135+
if (tool.inputSchema() != null) {
136+
String schemaJson = objectMapper.writeValueAsString(tool.inputSchema());
137+
assertThat(schemaJson).isNotBlank();
138+
139+
// Should be valid JSON
140+
objectMapper.readTree(schemaJson);
141+
}
142+
}
143+
});
144+
}
145+
146+
/**
147+
* Verifies that tools with complex parameter types work correctly.
148+
*/
149+
@Test
150+
@SuppressWarnings("unchecked")
151+
void mcpToolWithComplexParametersShouldWorkWithStdio() {
152+
this.contextRunner.withBean(TestComplexTools.class).run(context -> {
153+
assertThat(context).hasSingleBean(McpSyncServer.class);
154+
McpSyncServer syncServer = context.getBean(McpSyncServer.class);
155+
156+
McpAsyncServer asyncServer = (McpAsyncServer) ReflectionTestUtils.getField(syncServer, "asyncServer");
157+
158+
CopyOnWriteArrayList<AsyncToolSpecification> tools = (CopyOnWriteArrayList<AsyncToolSpecification>) ReflectionTestUtils
159+
.getField(asyncServer, "tools");
160+
161+
assertThat(tools).hasSize(1);
162+
163+
AsyncToolSpecification spec = tools.get(0);
164+
assertThat(spec.tool().name()).isEqualTo("processData");
165+
166+
// Verify the tool can be serialized
167+
ObjectMapper objectMapper = context.getBean("mcpServerObjectMapper", ObjectMapper.class);
168+
String toolJson = objectMapper.writeValueAsString(spec.tool());
169+
assertThat(toolJson).isNotBlank();
170+
});
171+
}
172+
173+
// Test components
174+
175+
@Component
176+
static class TestCalculatorTools {
177+
178+
@McpTool(name = "add", description = "Add two numbers")
179+
public int add(@McpToolParam(description = "First number", required = true) int a,
180+
@McpToolParam(description = "Second number", required = true) int b) {
181+
return a + b;
182+
}
183+
184+
@McpTool(name = "subtract", description = "Subtract two numbers")
185+
public int subtract(@McpToolParam(description = "First number", required = true) int a,
186+
@McpToolParam(description = "Second number", required = true) int b) {
187+
return a - b;
188+
}
189+
190+
@McpTool(name = "multiply", description = "Multiply two numbers")
191+
public int multiply(@McpToolParam(description = "First number", required = true) int a,
192+
@McpToolParam(description = "Second number", required = true) int b) {
193+
return a * b;
194+
}
195+
196+
}
197+
198+
@Component
199+
static class TestComplexTools {
200+
201+
@McpTool(name = "processData", description = "Process complex data")
202+
public String processData(@McpToolParam(description = "Input data", required = true) String input,
203+
@McpToolParam(description = "Options", required = false) String options) {
204+
return "Processed: " + input + " with options: " + options;
205+
}
206+
207+
}
208+
209+
// Test beans for ObjectMapper configuration verification
210+
211+
static class EmptyBean {
212+
213+
}
214+
215+
static class BeanWithNull {
216+
217+
public String value = null;
218+
219+
public String anotherValue = "test";
220+
221+
}
222+
223+
}

0 commit comments

Comments
 (0)