Skip to content

Commit 4273328

Browse files
liugddxericbottard
authored andcommitted
Introduce McpServerObjectMapperFactory for consistent ObjectMapper configuration
Signed-off-by: liugddx <[email protected]> Signed-off-by: Eric Bottard <[email protected]>
1 parent 6a34458 commit 4273328

File tree

6 files changed

+348
-7
lines changed

6 files changed

+348
-7
lines changed

auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/java/org/springframework/ai/mcp/server/common/autoconfigure/McpServerAutoConfiguration.java

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,31 @@ public class McpServerAutoConfiguration {
9191

9292
private static final LogAccessor logger = new LogAccessor(McpServerAutoConfiguration.class);
9393

94+
/**
95+
* Creates a configured ObjectMapper for MCP server JSON serialization.
96+
* <p>
97+
* This ObjectMapper is specifically configured for MCP protocol compliance with:
98+
* <ul>
99+
* <li>Lenient deserialization that doesn't fail on unknown properties</li>
100+
* <li>Proper handling of empty beans during serialization</li>
101+
* <li>Exclusion of null values from JSON output</li>
102+
* <li>Standard Jackson modules for Java 8, JSR-310, and Kotlin support</li>
103+
* </ul>
104+
* <p>
105+
* This bean can be overridden by providing a custom ObjectMapper bean with the name
106+
* "mcpServerObjectMapper".
107+
* @return configured ObjectMapper instance for MCP server operations
108+
*/
109+
@Bean(name = "mcpServerObjectMapper")
110+
@ConditionalOnMissingBean(name = "mcpServerObjectMapper")
111+
public ObjectMapper mcpServerObjectMapper() {
112+
return McpServerObjectMapperFactory.createObjectMapper();
113+
}
114+
94115
@Bean
95116
@ConditionalOnMissingBean
96-
public McpServerTransportProviderBase stdioServerTransport(ObjectProvider<ObjectMapper> objectMapperProvider) {
97-
ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new);
98-
99-
return new StdioServerTransportProvider(new JacksonMcpJsonMapper(objectMapper));
117+
public McpServerTransportProviderBase stdioServerTransport(ObjectMapper mcpServerObjectMapper) {
118+
return new StdioServerTransportProvider(new JacksonMcpJsonMapper(mcpServerObjectMapper));
100119
}
101120

102121
@Bean
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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+
*
49+
* @author Spring AI Team
50+
*/
51+
public final class McpServerObjectMapperFactory {
52+
53+
private McpServerObjectMapperFactory() {
54+
// Utility class - prevent instantiation
55+
}
56+
57+
/**
58+
* Creates a new {@link ObjectMapper} instance configured for MCP server operations.
59+
* <p>
60+
* This method creates a fresh ObjectMapper with standard configuration suitable for
61+
* MCP protocol serialization/deserialization. Each call creates a new instance, so
62+
* callers may want to cache the result if creating multiple instances.
63+
* @return a properly configured ObjectMapper instance
64+
*/
65+
public static ObjectMapper createObjectMapper() {
66+
return JsonMapper.builder()
67+
// Deserialization configuration
68+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
69+
.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT)
70+
// Serialization configuration
71+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
72+
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
73+
.serializationInclusion(JsonInclude.Include.NON_NULL)
74+
// Register standard Jackson modules (Jdk8, JavaTime, ParameterNames, Kotlin)
75+
.addModules(JacksonUtils.instantiateAvailableModules())
76+
.build();
77+
}
78+
79+
/**
80+
* Retrieves an ObjectMapper from the provided provider, or creates a configured
81+
* default if none is available.
82+
* <p>
83+
* This method is designed for use in Spring auto-configuration classes where an
84+
* ObjectMapper may optionally be provided by the user. If no ObjectMapper bean is
85+
* available, this method ensures a properly configured instance is used rather than a
86+
* vanilla ObjectMapper.
87+
* <p>
88+
* Example usage in auto-configuration:
89+
*
90+
* <pre>{@code
91+
* &#64;Bean
92+
* public TransportProvider transport(ObjectProvider<ObjectMapper> objectMapperProvider) {
93+
* ObjectMapper mapper = McpServerObjectMapperFactory.getOrCreateObjectMapper(objectMapperProvider);
94+
* return new TransportProvider(mapper);
95+
* }
96+
* }</pre>
97+
* @param objectMapperProvider the Spring ObjectProvider for ObjectMapper beans
98+
* @return the provided ObjectMapper, or a newly configured default instance
99+
*/
100+
public static ObjectMapper getOrCreateObjectMapper(
101+
org.springframework.beans.factory.ObjectProvider<ObjectMapper> objectMapperProvider) {
102+
return objectMapperProvider.getIfAvailable(McpServerObjectMapperFactory::createObjectMapper);
103+
}
104+
105+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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+
*/
45+
public class McpToolWithStdioIT {
46+
47+
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner().withConfiguration(
48+
AutoConfigurations.of(McpServerAutoConfiguration.class, McpServerAnnotationScannerAutoConfiguration.class,
49+
McpServerSpecificationFactoryAutoConfiguration.class));
50+
51+
/**
52+
* Verifies that a configured ObjectMapper bean is created for MCP server operations.
53+
*/
54+
@Test
55+
void shouldCreateConfiguredObjectMapperForMcpServer() {
56+
this.contextRunner.run(context -> {
57+
assertThat(context).hasSingleBean(ObjectMapper.class);
58+
ObjectMapper objectMapper = context.getBean("mcpServerObjectMapper", ObjectMapper.class);
59+
60+
assertThat(objectMapper).isNotNull();
61+
62+
// Verify that the ObjectMapper is properly configured
63+
String emptyBeanJson = objectMapper.writeValueAsString(new EmptyBean());
64+
assertThat(emptyBeanJson).isEqualTo("{}"); // Should not fail on empty beans
65+
66+
String nullValueJson = objectMapper.writeValueAsString(new BeanWithNull());
67+
assertThat(nullValueJson).doesNotContain("null"); // Should exclude null
68+
// values
69+
});
70+
}
71+
72+
/**
73+
* Verifies that STDIO transport uses the configured ObjectMapper.
74+
*/
75+
@Test
76+
void stdioTransportShouldUseConfiguredObjectMapper() {
77+
this.contextRunner.run(context -> {
78+
assertThat(context).hasSingleBean(McpServerTransportProviderBase.class);
79+
assertThat(context.getBean(McpServerTransportProviderBase.class))
80+
.isInstanceOf(StdioServerTransportProvider.class);
81+
82+
// Verify that the MCP server was created successfully
83+
assertThat(context).hasSingleBean(McpSyncServer.class);
84+
});
85+
}
86+
87+
/**
88+
* Verifies that @McpTool annotated methods are successfully registered with STDIO
89+
* transport and that tool specifications can be properly serialized to JSON without
90+
* errors.
91+
*/
92+
@Test
93+
@SuppressWarnings("unchecked")
94+
void mcpToolAnnotationsShouldWorkWithStdio() {
95+
this.contextRunner.withBean(TestCalculatorTools.class).run(context -> {
96+
// Verify the server was created
97+
assertThat(context).hasSingleBean(McpSyncServer.class);
98+
McpSyncServer syncServer = context.getBean(McpSyncServer.class);
99+
100+
// Get the async server from sync server (internal structure)
101+
McpAsyncServer asyncServer = (McpAsyncServer) ReflectionTestUtils.getField(syncServer, "asyncServer");
102+
assertThat(asyncServer).isNotNull();
103+
104+
// Verify that tools were registered
105+
CopyOnWriteArrayList<AsyncToolSpecification> tools = (CopyOnWriteArrayList<AsyncToolSpecification>) ReflectionTestUtils
106+
.getField(asyncServer, "tools");
107+
108+
assertThat(tools).isNotEmpty();
109+
assertThat(tools).hasSize(3);
110+
111+
// Verify tool names
112+
List<String> toolNames = tools.stream().map(spec -> spec.tool().name()).toList();
113+
assertThat(toolNames).containsExactlyInAnyOrder("add", "subtract", "multiply");
114+
115+
// Verify that each tool has a valid inputSchema that can be serialized
116+
ObjectMapper objectMapper = context.getBean("mcpServerObjectMapper", ObjectMapper.class);
117+
118+
for (AsyncToolSpecification spec : tools) {
119+
McpSchema.Tool tool = spec.tool();
120+
121+
// Verify basic tool properties
122+
assertThat(tool.name()).isNotBlank();
123+
assertThat(tool.description()).isNotBlank();
124+
125+
// Verify inputSchema can be serialized to JSON without errors
126+
if (tool.inputSchema() != null) {
127+
String schemaJson = objectMapper.writeValueAsString(tool.inputSchema());
128+
assertThat(schemaJson).isNotBlank();
129+
130+
// Should be valid JSON
131+
objectMapper.readTree(schemaJson);
132+
}
133+
}
134+
});
135+
}
136+
137+
/**
138+
* Verifies that tools with complex parameter types work correctly.
139+
*/
140+
@Test
141+
@SuppressWarnings("unchecked")
142+
void mcpToolWithComplexParametersShouldWorkWithStdio() {
143+
this.contextRunner.withBean(TestComplexTools.class).run(context -> {
144+
assertThat(context).hasSingleBean(McpSyncServer.class);
145+
McpSyncServer syncServer = context.getBean(McpSyncServer.class);
146+
147+
McpAsyncServer asyncServer = (McpAsyncServer) ReflectionTestUtils.getField(syncServer, "asyncServer");
148+
149+
CopyOnWriteArrayList<AsyncToolSpecification> tools = (CopyOnWriteArrayList<AsyncToolSpecification>) ReflectionTestUtils
150+
.getField(asyncServer, "tools");
151+
152+
assertThat(tools).hasSize(1);
153+
154+
AsyncToolSpecification spec = tools.get(0);
155+
assertThat(spec.tool().name()).isEqualTo("processData");
156+
157+
// Verify the tool can be serialized
158+
ObjectMapper objectMapper = context.getBean("mcpServerObjectMapper", ObjectMapper.class);
159+
String toolJson = objectMapper.writeValueAsString(spec.tool());
160+
assertThat(toolJson).isNotBlank();
161+
});
162+
}
163+
164+
// Test components
165+
166+
@Component
167+
static class TestCalculatorTools {
168+
169+
@McpTool(name = "add", description = "Add two numbers")
170+
public int add(@McpToolParam(description = "First number", required = true) int a,
171+
@McpToolParam(description = "Second number", required = true) int b) {
172+
return a + b;
173+
}
174+
175+
@McpTool(name = "subtract", description = "Subtract two numbers")
176+
public int subtract(@McpToolParam(description = "First number", required = true) int a,
177+
@McpToolParam(description = "Second number", required = true) int b) {
178+
return a - b;
179+
}
180+
181+
@McpTool(name = "multiply", description = "Multiply two numbers")
182+
public int multiply(@McpToolParam(description = "First number", required = true) int a,
183+
@McpToolParam(description = "Second number", required = true) int b) {
184+
return a * b;
185+
}
186+
187+
}
188+
189+
@Component
190+
static class TestComplexTools {
191+
192+
@McpTool(name = "processData", description = "Process complex data")
193+
public String processData(@McpToolParam(description = "Input data", required = true) String input,
194+
@McpToolParam(description = "Options", required = false) String options) {
195+
return "Processed: " + input + " with options: " + options;
196+
}
197+
198+
}
199+
200+
// Test beans for ObjectMapper configuration verification
201+
202+
static class EmptyBean {
203+
204+
}
205+
206+
static class BeanWithNull {
207+
208+
public String value = null;
209+
210+
public String anotherValue = "test";
211+
212+
}
213+
214+
}

auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webmvc/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerSseWebMvcAutoConfiguration.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import io.modelcontextprotocol.spec.McpServerTransportProvider;
2323

2424
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerAutoConfiguration;
25+
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerObjectMapperFactory;
2526
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerStdioDisabledCondition;
2627
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerSseProperties;
2728
import org.springframework.beans.factory.ObjectProvider;
@@ -78,7 +79,7 @@ public class McpServerSseWebMvcAutoConfiguration {
7879
public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider(
7980
ObjectProvider<ObjectMapper> objectMapperProvider, McpServerSseProperties serverProperties) {
8081

81-
ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new);
82+
ObjectMapper objectMapper = McpServerObjectMapperFactory.getOrCreateObjectMapper(objectMapperProvider);
8283

8384
return WebMvcSseServerTransportProvider.builder()
8485
.jsonMapper(new JacksonMcpJsonMapper(objectMapper))

auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webmvc/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerStatelessWebMvcAutoConfiguration.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport;
2222
import io.modelcontextprotocol.spec.McpSchema;
2323

24+
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerObjectMapperFactory;
2425
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerStatelessAutoConfiguration;
2526
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerStdioDisabledCondition;
2627
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerStreamableHttpProperties;
@@ -50,7 +51,7 @@ public class McpServerStatelessWebMvcAutoConfiguration {
5051
public WebMvcStatelessServerTransport webMvcStatelessServerTransport(
5152
ObjectProvider<ObjectMapper> objectMapperProvider, McpServerStreamableHttpProperties serverProperties) {
5253

53-
ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new);
54+
ObjectMapper objectMapper = McpServerObjectMapperFactory.getOrCreateObjectMapper(objectMapperProvider);
5455

5556
return WebMvcStatelessServerTransport.builder()
5657
.jsonMapper(new JacksonMcpJsonMapper(objectMapper))

0 commit comments

Comments
 (0)