From 3e48ba9343e2891e5716b7a916d464b6c6c97174 Mon Sep 17 00:00:00 2001 From: Nicholas Walter Knize Date: Fri, 16 Jan 2026 12:54:00 -0600 Subject: [PATCH] feat: Add JPMS compatibility to Jackson JSON mapper Configure ObjectMapper with JPMS-compatible settings: - Disable CAN_OVERRIDE_ACCESS_MODIFIERS to prevent setAccessible() calls - Add ParameterNamesModule to discover constructor parameter names from bytecode instead of reflection This allows applications using the MCP SDK to operate without `--add-opens` JVM flags, enabling full JPMS module encapsulation. The SDK already compiles with `-parameters` flag, which is required for ParameterNamesModule to function. Signed-off-by: Nicholas Walter Knize --- mcp-json-jackson2/pom.xml | 5 + .../jackson/JacksonMcpJsonMapperSupplier.java | 32 +++- .../json/jackson/JpmsCompatibilityTests.java | 146 ++++++++++++++++++ 3 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/JpmsCompatibilityTests.java diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index de2ac58ce..071b49b29 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -44,6 +44,11 @@ jackson-databind ${jackson.version} + + com.fasterxml.jackson.module + jackson-module-parameter-names + ${jackson.version} + com.networknt json-schema-validator diff --git a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java index 0e79c3e0e..c0113c50c 100644 --- a/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java +++ b/mcp-json-jackson2/src/main/java/io/modelcontextprotocol/json/jackson/JacksonMcpJsonMapperSupplier.java @@ -4,6 +4,10 @@ package io.modelcontextprotocol.json.jackson; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.McpJsonMapperSupplier; @@ -12,7 +16,7 @@ * serialization and deserialization. *

* This implementation provides a {@link McpJsonMapper} backed by a Jackson - * {@link com.fasterxml.jackson.databind.ObjectMapper}. + * {@link ObjectMapper} configured for JPMS (Java Platform Module System) compatibility. */ public class JacksonMcpJsonMapperSupplier implements McpJsonMapperSupplier { @@ -20,13 +24,33 @@ public class JacksonMcpJsonMapperSupplier implements McpJsonMapperSupplier { * Returns a new instance of {@link McpJsonMapper} that uses the Jackson library for * JSON serialization and deserialization. *

- * The returned {@link McpJsonMapper} is backed by a new instance of - * {@link com.fasterxml.jackson.databind.ObjectMapper}. + * The returned {@link McpJsonMapper} is backed by a JPMS-compatible + * {@link ObjectMapper} that does not require {@code --add-opens} JVM flags. * @return a new {@link McpJsonMapper} instance */ @Override public McpJsonMapper get() { - return new JacksonMcpJsonMapper(new com.fasterxml.jackson.databind.ObjectMapper()); + return new JacksonMcpJsonMapper(createJpmsCompatibleMapper()); + } + + /** + * Creates an ObjectMapper configured for JPMS compatibility. + *

+ * The mapper is configured to: + *

    + *
  • Not call {@code setAccessible()} on constructors/fields, avoiding the need for + * {@code --add-opens} flags
  • + *
  • Use the {@link ParameterNamesModule} to discover constructor parameter names + * from bytecode (requires {@code -parameters} compiler flag, which is already + * configured in the parent pom.xml)
  • + *
+ * @return a JPMS-compatible ObjectMapper + */ + private static ObjectMapper createJpmsCompatibleMapper() { + return JsonMapper.builder() + .disable(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS) + .addModule(new ParameterNamesModule()) + .build(); } } diff --git a/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/JpmsCompatibilityTests.java b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/JpmsCompatibilityTests.java new file mode 100644 index 000000000..644294532 --- /dev/null +++ b/mcp-json-jackson2/src/test/java/io/modelcontextprotocol/json/jackson/JpmsCompatibilityTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2025 - 2025 the original author or authors. + */ + +package io.modelcontextprotocol.json.jackson; + +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.modelcontextprotocol.json.McpJsonMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests verifying JPMS (Java Platform Module System) compatibility. + *

+ * These tests ensure that JSON deserialization of Java records works without requiring + * {@code --add-opens} JVM flags. + */ +public class JpmsCompatibilityTests { + + private McpJsonMapper jsonMapper; + + // Test records must be public for JPMS-compatible Jackson to access them + public record SimpleRecord(String name, String description) { + } + + public record RecordWithMap(String type, Map properties) { + } + + public record RecordWithList(List items, boolean enabled) { + } + + public record NestedRecord(String id, SimpleRecord nested) { + } + + @BeforeEach + void setUp() { + jsonMapper = new JacksonMcpJsonMapperSupplier().get(); + } + + @Test + @DisplayName("Should deserialize simple record without reflection access") + void deserializeSimpleRecord() throws Exception { + String json = """ + { + "name": "test-name", + "description": "A test description" + } + """; + + assertThatNoException().isThrownBy(() -> { + SimpleRecord record = jsonMapper.readValue(json, SimpleRecord.class); + assertThat(record.name()).isEqualTo("test-name"); + assertThat(record.description()).isEqualTo("A test description"); + }); + } + + @Test + @DisplayName("Should deserialize record with map without reflection access") + void deserializeRecordWithMap() throws Exception { + String json = """ + { + "type": "object", + "properties": { + "key1": "value1", + "key2": 42 + } + } + """; + + assertThatNoException().isThrownBy(() -> { + RecordWithMap record = jsonMapper.readValue(json, RecordWithMap.class); + assertThat(record.type()).isEqualTo("object"); + assertThat(record.properties()).containsKey("key1"); + }); + } + + @Test + @DisplayName("Should deserialize record with list without reflection access") + void deserializeRecordWithList() throws Exception { + String json = """ + { + "items": ["a", "b", "c"], + "enabled": true + } + """; + + assertThatNoException().isThrownBy(() -> { + RecordWithList record = jsonMapper.readValue(json, RecordWithList.class); + assertThat(record.enabled()).isTrue(); + assertThat(record.items()).containsExactly("a", "b", "c"); + }); + } + + @Test + @DisplayName("Should deserialize nested records without reflection access") + void deserializeNestedRecord() throws Exception { + String json = """ + { + "id": "outer-id", + "nested": { + "name": "inner-name", + "description": "inner-description" + } + } + """; + + assertThatNoException().isThrownBy(() -> { + NestedRecord record = jsonMapper.readValue(json, NestedRecord.class); + assertThat(record.id()).isEqualTo("outer-id"); + assertThat(record.nested().name()).isEqualTo("inner-name"); + }); + } + + @Test + @DisplayName("Should serialize and deserialize records round-trip") + void roundTripSerialization() throws Exception { + SimpleRecord original = new SimpleRecord("my-name", "my-description"); + + String json = jsonMapper.writeValueAsString(original); + SimpleRecord deserialized = jsonMapper.readValue(json, SimpleRecord.class); + + assertThat(deserialized.name()).isEqualTo(original.name()); + assertThat(deserialized.description()).isEqualTo(original.description()); + } + + @Test + @DisplayName("ObjectMapper should have JPMS-compatible configuration") + void verifyJpmsConfiguration() { + JacksonMcpJsonMapper jacksonMapper = (JacksonMcpJsonMapper) jsonMapper; + ObjectMapper objectMapper = jacksonMapper.getObjectMapper(); + + // Verify CAN_OVERRIDE_ACCESS_MODIFIERS is disabled + assertThat(objectMapper.isEnabled(MapperFeature.CAN_OVERRIDE_ACCESS_MODIFIERS)) + .as("CAN_OVERRIDE_ACCESS_MODIFIERS should be disabled for JPMS compatibility") + .isFalse(); + } + +}