java-sdk/CONTRIBUTING.md
Dariusz Jędrzejczyk 22a0efa577 feat!: enforce required MCP spec fields in McpSchema; lenient wire deserialization (#928)
Every wire-serialized record in McpSchema now validates spec-required
fields at construction time. Wire deserialization is intentionally
lenient: a missing required field is replaced with a documented default
and a WARN is logged, instead of failing the parse. A required-first
builder convention is introduced across the schema so it is no longer
possible to obtain a builder that is missing required state.

Required-field guards
---------------------
Every wire record's compact constructor asserts non-null (and non-empty
for String identifiers like name, uri, uriTemplate, version) on its
spec-required components. Passing null now throws
IllegalArgumentException at construction time instead of silently
producing invalid JSON via @JsonInclude(NON_ABSENT). This applies to
the JSON-RPC envelopes, lifecycle types, resource/prompt/tool requests
and results, sampling and elicitation, content records, root, complete,
logging and progress notifications, and the two CompleteReference
implementations. See MIGRATION-2.0.md for the full list.

Lenient wire deserialization
----------------------------
For each of those records (except JSONRPCResponse.JSONRPCError, which
still fails fast) a @JsonCreator static `fromJson` factory substitutes
a documented default for any absent required field — "" for strings,
[] for collections, {} for maps, 0 / 0.0 for numerics, INFO for
LoggingLevel, USER for SamplingMessage.role, ASSISTANT for
CreateMessageResult.role, CANCEL for ElicitResult.action, {values=[]}
for CompleteResult.completion — and logs a WARN naming the field and
the value used. Application code can still observe a malformed message
and react, but the SDK no longer halts the conversation.

Builder convention
------------------
Records that have a builder gain a required-first factory method
`builder(req1, req2, …)`; setters for required fields are removed from
the builder so it cannot be left in an invalid state. Existing no-arg
`builder()` factories and required-field setters are kept where source
compatibility demands it but are marked @Deprecated. New builders are
also added for several records that previously had none (ProgressNotification,
JSONRPCError, CompleteRequest, list/result types, content records, ...).

JSON-RPC envelope ergonomics
----------------------------
Previously every JSON-RPC envelope had to be constructed via the
canonical record constructor and the literal "2.0" string had to be
threaded through every call site, e.g.

    new JSONRPCRequest("2.0", "tools/call", id, params)
    new JSONRPCResponse("2.0", id, result, null)
    new JSONRPCResponse("2.0", id, null, new JSONRPCError(code, message, null))

Now:

    new JSONRPCRequest("tools/call", id, params)              // jsonrpc defaulted
    new JSONRPCNotification("notifications/initialized")       // params optional
    JSONRPCResponse.result(id, result)                        // factory
    JSONRPCResponse.error(id, new JSONRPCError(code, message)) // 2-arg error

JSONRPCResponse's compact constructor additionally enforces the
JSON-RPC invariant that exactly one of `result` / `error` is set —
previously the SDK could build envelopes that violated the protocol.

CompleteReference changes
-------------------------
- PromptReference.equals/hashCode now key on `name` only (previously
  derived from identifier()+type()). Two refs with the same name but
  different titles now compare equal — code using PromptReference as a
  map/set key should be audited.
- PromptReference's compact constructor pins `type` to "ref/prompt"
  and logs a WARN if the caller supplies a different non-null value.
  The legacy two-arg `PromptReference(String type, String name)`
  constructor remains @Deprecated.
- ResourceReference's record components are reduced from (type, uri)
  to (uri) — positional construction breaks. The legacy
  `ResourceReference(String type, String uri)` constructor stays
  @Deprecated and ignores `type`.
- CompleteReference.identifier() is @Deprecated and now returns null
  via a default method on the interface.

Tests / refactor
----------------
Conformance harness, integration tests, sample apps, and internal
callers were migrated to the new builder factories and convenience
constructors. No semantic changes to client/server runtime behaviour
beyond the McpSchema changes above.

Docs
----
- MIGRATION-2.0.md: required-field section now covers the broader
  record set and the lenient-deserialization behaviour; PromptReference
  WARN behaviour and ResourceReference component reduction are
  documented; the JSON-RPC envelope section is rewritten to compare the
  pre-2.0 surface with the new one.
- CONTRIBUTING.md: the "Evolving wire-serialized records" recipe is
  split into two cases — adding a new optional field (existing rules)
  and adding/maintaining a spec-required field (new rules covering
  Assert in compact constructor, @JsonCreator fromJson with defaults
  and WARN, required-first builder factory).

Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com>
2026-05-04 21:01:46 +02:00

8.7 KiB

Contributing to Model Context Protocol Java SDK

Thank you for your interest in contributing to the Model Context Protocol Java SDK! This document outlines how to contribute to this project.

Prerequisites

The following software is required to work on the codebase:

  • Java 17 or above
  • Docker
  • npx

Getting Started

  1. Fork the repository
  2. Clone your fork:
git clone https://github.com/YOUR-USERNAME/java-sdk.git
cd java-sdk
  1. Build from source:
./mvnw clean install -DskipTests # skip the tests
./mvnw test # run tests

Reporting Issues

Please create an issue in the repository if you discover a bug or would like to propose an enhancement. Bug reports should have a reproducer in the form of a code sample or a repository attached that the maintainers or contributors can work with to address the problem.

Making Changes

  1. Create a new branch:
git checkout -b feature/your-feature-name
  1. Make your changes
  2. Validate your changes:
./mvnw clean test

Change Proposal Guidelines

Principles of MCP

  1. Simple + Minimal: It is much easier to add things to the codebase than it is to remove them. To maintain simplicity, we keep a high bar for adding new concepts and primitives as each addition requires maintenance and compatibility consideration.
  2. Concrete: Code changes need to be based on specific usage and implementation challenges and not on speculative ideas. Most importantly, the SDK is meant to implement the MCP specification.

Submitting Changes

  1. For non-trivial changes, please clarify with the maintainers in an issue whether you can contribute the change and the desired scope of the change.
  2. For trivial changes (for example a couple of lines or documentation changes) there is no need to open an issue first.
  3. Push your changes to your fork.
  4. Submit a pull request to the main repository.
  5. Follow the pull request template.
  6. Wait for review.
  7. For any follow-up work, please add new commits instead of force-pushing. This will allow the reviewer to focus on incremental changes instead of having to restart the review process.

Evolving wire-serialized records

Records in McpSchema are serialized directly to the MCP JSON wire format. The rules differ depending on whether the field you are adding (or maintaining) is optional — Java code may legitimately leave it null and the wire may omit it — or spec-required by MCP. Follow Case A for optional fields and Case B for spec-required fields.

Case A — Optional fields

  1. Add new components only at the end of the record's component list. Never reorder or rename existing components.
  2. Annotate every component with @JsonProperty("fieldName") even when the Java name already matches. This survives local renames via refactoring tools.
  3. Use boxed types (Boolean, Integer, Long, Double) so the field can be absent on the wire without a special sentinel.
  4. Default to null, not an empty collection or neutral value, so the @JsonInclude(NON_ABSENT) rule omits the field for clients that don't know about it yet.
  5. Keep existing constructors as source-compatible overloads that delegate to the new canonical constructor and pass null for the new component. Do not remove them in the same release that adds the field.
  6. Do not put @JsonCreator on the canonical constructor unless strictly necessary. Jackson auto-detects record canonical constructors; adding @JsonCreator pins deserialization to that exact parameter order forever. (For records that also have spec-required fields, the @JsonCreator belongs on a separate static fromJson factory — see Case B, Rule 2.)
  7. Do not convert null to a default value in the canonical constructor. Null carries "absent" semantics and must be preserved through the serialization round-trip. (Spec-required fields are the exception — see Case B, Rule 1.)
  8. Add three tests per new field (put them in the relevant test class in mcp-test):
    • Deserialize JSON without the field → succeeds, field is null.
    • Serialize an instance with the field unset (null) → the key is absent from output.
    • Deserialize JSON with an extra unknown field → succeeds.
  9. An inner Builder subclass can be used. This improves the developer experience since frequently not all fields are required.

Case B — Spec-required fields

When the MCP specification marks a field as required, callers must not be able to construct a structurally invalid record, but the wire parser must still tolerate peers that fail to send it. Follow these rules in addition to the relevant Case A rules (annotation, naming, append-only).

  1. Reject null in the compact constructor. Use Assert.notNull for required objects or Assert.hasText for required String identifiers (name, uri, uriTemplate, version). This throws IllegalArgumentException at construction time instead of producing a record that fails later in serialization or protocol handling. Overrides Case A Rule 7 for this field.
  2. Add a @JsonCreator static fromJson factory alongside the canonical constructor. When a required field is absent from the wire, substitute a documented safe default ("" for strings, [] for collections, {} for maps, 0 / 0.0 for numerics, INFO for LoggingLevel, etc.) and log at WARN naming the field and the value used. The SDK must not halt the conversation because of a missing field. Place @JsonCreator on this fromJson factory, never on the canonical constructor (Case A Rule 6 still applies to the canonical constructor itself).
    • Exception: JSONRPCResponse.JSONRPCError fails fast on missing code / message because a malformed JSON-RPC error envelope is unrecoverable.
  3. Provide a required-first builder factory builder(req1, req2, …) and remove the corresponding setters from the Builder. A no-arg builder() factory must not exist on a record that has required fields. If one already exists for source compatibility, mark it @Deprecated.
  4. Add tests per required field:
    • Constructing the record with null for the field throws IllegalArgumentException.
    • Deserializing JSON without the field succeeds and yields the documented default.
    • Deserializing JSON with an extra unknown field still succeeds (Case A Rule 8 also applies).

Example

Suppose ToolAnnotations gains an optional audience field:

// Before
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public record ToolAnnotations(
    @JsonProperty("title") String title,
    @JsonProperty("readOnlyHint") Boolean readOnlyHint,
    @JsonProperty("destructiveHint") Boolean destructiveHint,
    @JsonProperty("idempotentHint") Boolean idempotentHint,
    @JsonProperty("openWorldHint") Boolean openWorldHint) { ... }

// After — new component appended at the end
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public record ToolAnnotations(
    @JsonProperty("title") String title,
    @JsonProperty("readOnlyHint") Boolean readOnlyHint,
    @JsonProperty("destructiveHint") Boolean destructiveHint,
    @JsonProperty("idempotentHint") Boolean idempotentHint,
    @JsonProperty("openWorldHint") Boolean openWorldHint,
    @JsonProperty("audience") List<String> audience) {   // new — added at end

    // Keep the old constructor so existing callers still compile
    public ToolAnnotations(String title, Boolean readOnlyHint,
            Boolean destructiveHint, Boolean idempotentHint, Boolean openWorldHint) {
        this(title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint, null);
    }
}

Tests to add:

@Test
void toolAnnotationsDeserializesWithoutAudience() throws IOException {
    ToolAnnotations a = mapper.readValue("""
            {"title":"My tool","readOnlyHint":true}""", ToolAnnotations.class);
    assertThat(a.audience()).isNull();
}

@Test
void toolAnnotationsOmitsNullAudience() throws IOException {
    String json = mapper.writeValueAsString(new ToolAnnotations("t", null, null, null, null));
    assertThat(json).doesNotContain("audience");
}

@Test
void toolAnnotationsToleratesUnknownFields() throws IOException {
    ToolAnnotations a = mapper.readValue("""
            {"title":"t","futureField":42}""", ToolAnnotations.class);
    assertThat(a.title()).isEqualTo("t");
}

Code of Conduct

This project follows a Code of Conduct. Please review it in CODE_OF_CONDUCT.md.

Questions

If you have questions, please create a discussion in the repository.

License

By contributing, you agree that your contributions will be licensed under the MIT License.

Security

Please review our Security Policy for reporting security issues.