java-sdk/CONTRIBUTING.md
Dariusz Jędrzejczyk 4c8596306d feat!: consistent JSON forward/backward compatibility - 2.0 foundation (#972)
The MCP specification evolves continuously; domain types must absorb
new fields and subtypes without breaking existing clients or servers.
On the 1.x line this is structurally prevented by sealed interfaces,
which make it impossible to add a permitted subtype without breaking
exhaustive pattern-match switch expressions in caller code. This
commit opens the 2.0 release line, where those constraints are lifted
and serialization is made self-contained — independent of any global
ObjectMapper configuration.

Breaking changes for users migrating from 1.x

- Sealed interfaces removed from JSONRPCMessage, Request, Result,
  Notification, ResourceContents, CompleteReference and Content.
  Exhaustive switch expressions over these types must add a default
  branch.
- Prompt(name, description, null) no longer silently coerces null
  arguments to an empty list. Use Prompt.withDefaults() to preserve
  the previous behaviour.
- CompleteCompletion.total and .hasMore are now absent from the wire
  when not set, rather than being emitted as null.
- ServerParameters no longer carries Jackson annotations; it is an
  internal configuration class, not a wire type.

What now works that did not before

- CompleteReference polymorphic dispatch (PromptReference vs
  ResourceReference) works through a plain readValue or convertValue
  call — no hand-rolled map inspection required.
- LoggingLevel deserialization is lenient: unknown level strings
  produce null instead of throwing.
- All domain records now tolerate unknown JSON fields, so a client
  built against an older SDK version will not fail when a newer server
  sends fields it does not yet recognise.
- Null optional fields are consistently absent from serialized output
  regardless of ObjectMapper configuration.

Documentation

- CONTRIBUTING adds an "Evolving wire-serialized records" section: a
  9-rule recipe and example for adding a field safely.
- MIGRATION-2.0 documents all breaking changes listed above.

Follow-up coming next

Several spec-required fields (e.g. JSONRPCError.code/message,
ProgressNotification.progress, CreateMessageRequest.maxTokens,
CallToolResult.content) are stored as nullable Java types without a
null guard. If constructed with null, the NON_ABSENT rule silently
omits them, producing invalid wire JSON without throwing. Fix: compact
canonical constructors with Assert.notNull, following the pattern
already in JSONRPCRequest.

Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com>
2026-04-23 17:34:55 +03:00

6.4 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. Follow these rules whenever you add a field to an existing record to keep the protocol forward- and backward-compatible.

Rules

  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_NULL) 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.
  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.
  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.

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.