Skip to content

Several request records in io.modelcontextprotocol.spec.McpSchema cannot be deserialized correctly by the Jackson 3 mapper #978

@iuliiasobolevska

Description

@iuliiasobolevska

Please do a quick search on GitHub issues first, there might be already a duplicate issue for the one you are about to create.
If the bug is trivial, just go ahead and create the issue. Otherwise, please take a few moments and fill in the following sections:

Bug description

Several request records in io.modelcontextprotocol.spec.McpSchema cannot be deserialized correctly by the Jackson 3 mapper (mcp-json-jackson3). Required fields silently bind to null, which then causes a NullPointerException in the stateless transports when they read those fields (e.g. WebMvcStatelessServerTransport calling request.name().equals(...)).

Affected records (all in McpSchema):

Record Required fields that silently bind to null
CallToolRequest name
InitializeRequest protocolVersion, capabilities, clientInfo
GetPromptRequest name
ReadResourceRequest uri

Each of these records declares three candidate creators:

  1. The canonical record constructor (parameters carry @JsonProperty).
  2. One or more deprecated alternate constructors with no Jackson annotations.
  3. A package-private @JsonCreator-annotated static fromJson(...) factory that defaults missing required fields to "" and logs a warning.

With Jackson 2, the static fromJson factory is selected as the creator: @JsonProperty bindings on its parameters are applied, and if a required field is missing from the JSON, fromJson substitutes "" and logs a warning. With Jackson 3, creator resolution does not route through fromJson; the canonical record constructor is used directly, but the @JsonProperty parameter bindings are not applied, so name / uri / etc. end up null even when present in the payload.

Root cause: Jackson 3 tightened record creator detection. For records, the canonical constructor is treated as an implicit PROPERTIES-mode creator and is preferred; an @JsonCreator on a package-private static factory no longer reliably overrides it, and the unannotated deprecated alternate constructors add further ambiguity.

The resulting failure surfaces as e.g.:

java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because the return value of
  "io.modelcontextprotocol.spec.McpSchema$CallToolRequest.name()" is null
  at io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport...

Proposed fix: for each affected record, collapse to a single, unambiguous canonical creator: declare the canonical constructor explicitly, annotate it @JsonCreator, fold the null→"" defaulting from fromJson into it, and remove both the fromJson factory and the unannotated deprecated alternate constructors.

Environment

  • io.modelcontextprotocol.sdk:mcp-core: 2.0.0-M2 / 2.0.0-M3 (current main at 2.0.0-SNAPSHOT has the same shape)
  • io.modelcontextprotocol.sdk:mcp-json-jackson3: 2.0.0-M2 and later
  • Jackson 3.x (tools.jackson.databind)
  • Java 21
  • Spring AI spring-ai-starter-mcp-server on Spring Boot 3.x (also reproduces standalone: Spring AI is not required)
  • Transport: any of the stateless transports (WebMvcStatelessServerTransport, etc.); surfaces wherever these records are deserialized

Steps to reproduce

  1. Add mcp-core and mcp-json-jackson3 to the classpath (so ServiceLoader picks the Jackson 3 supplier).
  2. Build a JacksonMcpJsonMapper wrapping a Jackson 3 JsonMapper.
  3. Deserialize a valid tools/call JSON payload into McpSchema.CallToolRequest.
  4. Observe that req.name() returns null even though the payload set "name": "...".

End-to-end variant: deploy any Spring AI MCP stateless server that picks up mcp-json-jackson3 via ServiceLoader, POST a tools/call request, observe the NPE in WebMvcStatelessServerTransport.

Expected behavior

Deserialization should bind name (and the other required fields on the other affected records) from the JSON payload, just as the Jackson 2 mapper does. With well-formed input, req.name() should equal the value of the "name" field in the JSON. With missing required fields, the documented fromJson defaulting behavior (default to "", log a warning) should apply, and the same outcome should be produced by both the Jackson 2 and Jackson 3 mappers.

Minimal Complete Reproducible example

import io.modelcontextprotocol.json.jackson3.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.Test;
import tools.jackson.databind.json.JsonMapper;

import static org.assertj.core.api.Assertions.assertThat;

class CallToolRequestJackson3Test {

  @Test
  void name_bindsFromJson() {
    var mapper = new JacksonMcpJsonMapper(new JsonMapper());

    var req = mapper.readValue(
        """
        {"name": "search_tool", "arguments": {"x": 1}, "_meta": {}}
        """,
        McpSchema.CallToolRequest.class);

    // Expected: "search_tool"
    // Actual:   null  -> downstream WebMvcStatelessServerTransport NPEs on name().equals(...)
    assertThat(req.name()).isEqualTo("search_tool");
  }
}

The same failure shape reproduces for InitializeRequest (protocolVersion / capabilities / clientInfo), GetPromptRequest (name), and ReadResourceRequest (uri).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions