Skip to content

Commit 6d9b8e6

Browse files
ericbottardliugddx
authored andcommitted
Simplify MCP Server ObjectMapper injection
- Add McpServerObjectMapperAutoConfiguration which includes mcpServerObjectMapper with the explicit bean name qualifier "mcpServerObjectMapper" and the configuration "defaultCandidate = false" - The "defaultCandidate = false" configuration makes sure the mcpServerObjectMapper doesn't get injected as the ObjectMapper bean when the qualifier "mcpServerObjectMapper" isn't explciitly specified. This allows other default or custom ObjectMapper beans to be in used in other parts of the applications. - Use qualified "mcpServerObjectMapper" bean name for the MCP specific ObjectMapper beans - Adjust tests Closes #4730 Co-authored-by: liugddx <[email protected]> Signed-off-by: Eric Bottard <[email protected]>
1 parent 690f400 commit 6d9b8e6

File tree

24 files changed

+358
-56
lines changed

24 files changed

+358
-56
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: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerChangeNotificationProperties;
5252
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerProperties;
5353
import org.springframework.beans.factory.ObjectProvider;
54+
import org.springframework.beans.factory.annotation.Qualifier;
5455
import org.springframework.boot.autoconfigure.AutoConfiguration;
5556
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
5657
import org.springframework.boot.autoconfigure.condition.AllNestedConditions;
@@ -93,10 +94,9 @@ public class McpServerAutoConfiguration {
9394

9495
@Bean
9596
@ConditionalOnMissingBean
96-
public McpServerTransportProviderBase stdioServerTransport(ObjectProvider<ObjectMapper> objectMapperProvider) {
97-
ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new);
98-
99-
return new StdioServerTransportProvider(new JacksonMcpJsonMapper(objectMapper));
97+
public McpServerTransportProviderBase stdioServerTransport(
98+
@Qualifier("mcpServerObjectMapper") ObjectMapper mcpServerObjectMapper) {
99+
return new StdioServerTransportProvider(new JacksonMcpJsonMapper(mcpServerObjectMapper));
100100
}
101101

102102
@Bean
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
import io.modelcontextprotocol.spec.McpSchema;
25+
26+
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerProperties;
27+
import org.springframework.ai.util.JacksonUtils;
28+
import org.springframework.boot.autoconfigure.AutoConfiguration;
29+
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
30+
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
31+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
32+
import org.springframework.context.annotation.Bean;
33+
34+
@AutoConfiguration
35+
@ConditionalOnClass(McpSchema.class)
36+
@ConditionalOnProperty(prefix = McpServerProperties.CONFIG_PREFIX, name = "enabled", havingValue = "true",
37+
matchIfMissing = true)
38+
@ConditionalOnMissingBean(name = "mcpServerObjectMapper")
39+
public class McpServerObjectMapperAutoConfiguration {
40+
41+
/**
42+
* Creates a configured ObjectMapper for MCP server JSON serialization.
43+
* <p>
44+
* This ObjectMapper is specifically configured for MCP protocol compliance with:
45+
* <ul>
46+
* <li>Lenient deserialization that doesn't fail on unknown properties</li>
47+
* <li>Proper handling of empty beans during serialization</li>
48+
* <li>Exclusion of null values from JSON output</li>
49+
* <li>Standard Jackson modules for Java 8, JSR-310, and Kotlin support</li>
50+
* </ul>
51+
* <p>
52+
* This bean can be overridden by providing a custom ObjectMapper bean with the name
53+
* "mcpServerObjectMapper".
54+
* @return configured ObjectMapper instance for MCP server operations
55+
*/
56+
// NOTE: defaultCandidate=false prevents this MCP specific mapper from being injected
57+
// in code that doesn't explicitly qualify injection point by name.
58+
@Bean(name = "mcpServerObjectMapper", defaultCandidate = false)
59+
public ObjectMapper mcpServerObjectMapper() {
60+
return JsonMapper.builder()
61+
// Deserialization configuration
62+
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
63+
.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT)
64+
// Serialization configuration
65+
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
66+
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
67+
.serializationInclusion(JsonInclude.Include.NON_NULL)
68+
// Register standard Jackson modules (Jdk8, JavaTime, ParameterNames, Kotlin)
69+
.addModules(JacksonUtils.instantiateAvailableModules())
70+
.build();
71+
}
72+
73+
}

auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-common/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# limitations under the License.
1515
#
1616
org.springframework.ai.mcp.server.common.autoconfigure.McpServerAutoConfiguration
17+
org.springframework.ai.mcp.server.common.autoconfigure.McpServerObjectMapperAutoConfiguration
1718
org.springframework.ai.mcp.server.common.autoconfigure.ToolCallbackConverterAutoConfiguration
1819
org.springframework.ai.mcp.server.common.autoconfigure.McpServerStatelessAutoConfiguration
1920
org.springframework.ai.mcp.server.common.autoconfigure.StatelessToolCallbackConverterAutoConfiguration
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-webflux/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerSseWebFluxAutoConfiguration.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerAutoConfiguration;
2525
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerStdioDisabledCondition;
2626
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerSseProperties;
27-
import org.springframework.beans.factory.ObjectProvider;
27+
import org.springframework.beans.factory.annotation.Qualifier;
2828
import org.springframework.boot.autoconfigure.AutoConfiguration;
2929
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
3030
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -82,10 +82,8 @@ public class McpServerSseWebFluxAutoConfiguration {
8282

8383
@Bean
8484
@ConditionalOnMissingBean
85-
public WebFluxSseServerTransportProvider webFluxTransport(ObjectProvider<ObjectMapper> objectMapperProvider,
86-
McpServerSseProperties serverProperties) {
87-
88-
ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new);
85+
public WebFluxSseServerTransportProvider webFluxTransport(
86+
@Qualifier("mcpServerObjectMapper") ObjectMapper objectMapper, McpServerSseProperties serverProperties) {
8987

9088
return WebFluxSseServerTransportProvider.builder()
9189
.jsonMapper(new JacksonMcpJsonMapper(objectMapper))

auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webflux/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerStatelessWebFluxAutoConfiguration.java

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerStatelessAutoConfiguration;
2525
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerStdioDisabledCondition;
2626
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerStreamableHttpProperties;
27-
import org.springframework.beans.factory.ObjectProvider;
27+
import org.springframework.beans.factory.annotation.Qualifier;
2828
import org.springframework.boot.autoconfigure.AutoConfiguration;
2929
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
3030
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -47,14 +47,12 @@ public class McpServerStatelessWebFluxAutoConfiguration {
4747
@Bean
4848
@ConditionalOnMissingBean
4949
public WebFluxStatelessServerTransport webFluxStatelessServerTransport(
50-
ObjectProvider<ObjectMapper> objectMapperProvider, McpServerStreamableHttpProperties serverProperties) {
51-
52-
ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new);
50+
@Qualifier("mcpServerObjectMapper") ObjectMapper objectMapper,
51+
McpServerStreamableHttpProperties serverProperties) {
5352

5453
return WebFluxStatelessServerTransport.builder()
5554
.jsonMapper(new JacksonMcpJsonMapper(objectMapper))
5655
.messageEndpoint(serverProperties.getMcpEndpoint())
57-
// .disallowDelete(serverProperties.isDisallowDelete())
5856
.build();
5957
}
6058

auto-configurations/mcp/spring-ai-autoconfigure-mcp-server-webflux/src/main/java/org/springframework/ai/mcp/server/autoconfigure/McpServerStreamableHttpWebFluxAutoConfiguration.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
import org.springframework.ai.mcp.server.common.autoconfigure.McpServerStdioDisabledCondition;
2626
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerProperties;
2727
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerStreamableHttpProperties;
28-
import org.springframework.beans.factory.ObjectProvider;
28+
import org.springframework.beans.factory.annotation.Qualifier;
2929
import org.springframework.boot.autoconfigure.AutoConfiguration;
3030
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
3131
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@@ -48,9 +48,8 @@ public class McpServerStreamableHttpWebFluxAutoConfiguration {
4848
@Bean
4949
@ConditionalOnMissingBean
5050
public WebFluxStreamableServerTransportProvider webFluxStreamableServerTransportProvider(
51-
ObjectProvider<ObjectMapper> objectMapperProvider, McpServerStreamableHttpProperties serverProperties) {
52-
53-
ObjectMapper objectMapper = objectMapperProvider.getIfAvailable(ObjectMapper::new);
51+
@Qualifier("mcpServerObjectMapper") ObjectMapper objectMapper,
52+
McpServerStreamableHttpProperties serverProperties) {
5453

5554
return WebFluxStreamableServerTransportProvider.builder()
5655
.jsonMapper(new JacksonMcpJsonMapper(objectMapper))

0 commit comments

Comments
 (0)