Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,83 @@ git checkout -b feature/your-feature-name
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:

```java
// 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:

```java
@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
Expand Down
84 changes: 84 additions & 0 deletions MIGRATION-2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Migration Guide — 2.0

This document covers breaking and behavioural changes introduced in the 2.0 release of the MCP Java SDK.

---

## Jackson / JSON serialization changes

### Sealed interfaces removed

The following interfaces were `sealed` in 1.x and are now plain interfaces in 2.0:

- `McpSchema.JSONRPCMessage`
- `McpSchema.Request`
- `McpSchema.Result`
- `McpSchema.Notification`
- `McpSchema.ResourceContents`
- `McpSchema.CompleteReference`
- `McpSchema.Content`

**Impact:** Exhaustive `switch` expressions or `switch` statements that relied on the sealed hierarchy for completeness checking must add a `default` branch. The compiler will no longer reject switches that omit one of the known subtypes.

### `CompleteReference` now carries `@JsonTypeInfo`

`CompleteReference` (and its implementations `PromptReference` and `ResourceReference`) is now annotated with `@JsonTypeInfo(use = NAME, include = EXISTING_PROPERTY, property = "type", visible = true)`. Jackson will automatically dispatch to the correct subtype based on the `"type"` field in the JSON without any hand-written map-walking code.

**Action:** Remove any custom code that manually inspected the `"type"` field of a completion reference map and instantiated `PromptReference` / `ResourceReference` by hand. A plain `mapper.readValue(json, CompleteRequest.class)` or `mapper.convertValue(paramsMap, CompleteRequest.class)` is sufficient.

### `Prompt` canonical constructor no longer coerces `null` arguments

In 1.x, `new Prompt(name, description, null)` silently stored an empty list for `arguments`. In 2.0 it stores `null`.

**Action:**

- Code that expected `prompt.arguments()` to return an empty list when not provided will now receive `null`. Add a null-check or use the new `Prompt.withDefaults(name, description, arguments)` factory, which preserves the old behaviour by coercing `null` to `[]`.
- On the wire, a prompt without an `arguments` field deserializes with `arguments == null` (it is not coerced to an empty list).

### `CompleteCompletion` optional fields omitted when null

`CompleteResult.CompleteCompletion.total` and `CompleteCompletion.hasMore` are now omitted from serialized JSON when they are `null` (previously they were always emitted). Deserializers that required these fields to be present in every response must be updated to treat their absence as `null`.

### `CompleteCompletion.values` is mandatory in the Java API

The compact constructor for `CompleteCompletion` asserts that `values` is not `null`. Code that constructed a completion result with a null `values` list will now fail at runtime.

**Action:** Always pass a non-null list (for example `List.of()` when there are no suggestions).

### `LoggingLevel` deserialization is lenient

`LoggingLevel` now uses a `@JsonCreator` factory (`fromValue`) so that JSON string values deserialize in a case-insensitive way. **Unrecognized level strings deserialize to `null`** instead of causing deserialization to fail.

**Impact:** `SetLevelRequest`, `LoggingMessageNotification`, and any other type that embeds `LoggingLevel` can observe a `null` level when the wire value is unknown or misspelled. Downstream code must null-check or validate before use.

### `Content.type()` is ignored for Jackson serialization

The `Content` interface still exposes `type()` as a convenience for Java callers, but the method is annotated with `@JsonIgnore` so Jackson does not treat it as a duplicate `"type"` property alongside `@JsonTypeInfo` on the interface.

**Impact:** Custom serializers or `ObjectMapper` configuration that relied on serializing `Content` through the default `type()` accessor alone should use the concrete content records (each of which carries a real `"type"` property) or the polymorphic setup on `Content`.

### `ServerParameters` no longer carries Jackson annotations

`ServerParameters` (in `client/transport`) has had its `@JsonProperty` and `@JsonInclude` annotations removed. It was never a wire type and is not serialized to JSON in normal SDK usage. If your code serialized or deserialized `ServerParameters` using Jackson, switch to a plain map or a dedicated DTO.

### Record annotation sweep

Wire-oriented `public record` types in `McpSchema` consistently use `@JsonInclude(JsonInclude.Include.NON_ABSENT)` (or equivalent per-type configuration) and `@JsonIgnoreProperties(ignoreUnknown = true)`. Nested capability objects under `ClientCapabilities` / `ServerCapabilities` (for example `Sampling`, `Elicitation`, `CompletionCapabilities`, `LoggingCapabilities`, prompt/resource/tool capability records) also ignore unknown JSON properties. This means:

- **Unknown fields** in incoming JSON are silently ignored, improving forward compatibility with newer server or client versions.
- **Absent optional properties** are omitted from outgoing JSON where `NON_ABSENT` applies, and optional Java components deserialize as `null` when missing on the wire.

### `Tool.inputSchema` is `Map<String, Object>`, not `JsonSchema`

The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSON Schema objects as `Map<String, Object>`, so dialect-specific keywords (`$ref`, `unevaluatedProperties`, vendor extensions, and so on) round-trip without being trimmed by a narrow `JsonSchema` record.

**Impact:**

- Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map<String, Object>` (or copy into your own schema wrapper).
- `Tool.Builder.inputSchema(JsonSchema)` remains as a **deprecated** helper that maps the old record into a map; prefer `inputSchema(Map)` or `inputSchema(McpJsonMapper, String)`.

### Optional JSON Schema validation on `tools/call` (server)

When a `JsonSchemaValidator` is available (including the default from `McpJsonDefaults.getSchemaValidator()` when you do not configure one explicitly) and `validateToolInputs` is left at its default of `true`, the server validates incoming tool arguments against `tool.inputSchema()` before invoking the tool. Failed validation produces a `CallToolResult` with `isError` set and a textual error in the content.

**Action:** Ensure `inputSchema` maps are valid for your validator, tighten client arguments, or disable validation with `validateToolInputs(false)` on the server builder if you must preserve pre-2.0 behaviour.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2024-2024 the original author or authors.
* Copyright 2024-2026 the original author or authors.
*/

package io.modelcontextprotocol.client.transport;
Expand All @@ -11,17 +11,15 @@
import java.util.Map;
import java.util.stream.Collectors;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.modelcontextprotocol.util.Assert;

/**
* Server parameters for stdio client.
* Server parameters for stdio client. This is not a wire type; Jackson annotations are
* intentionally omitted.
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public class ServerParameters {

// Environment variables to inherit by default
Expand All @@ -32,13 +30,10 @@ public class ServerParameters {
"SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE")
: Arrays.asList("HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER");

@JsonProperty("command")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If not mistaken the ServerParameters are modeling the Claude Desktop MCP json configuration (or subset of it) : https://modelcontextprotocol.io/docs/develop/connect-local-servers
Therefore the annotations serve this purpose.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's just a Claude thing, not specification thing. We don't use it to serialize/deserialize and we don't expect that kind of usage. These annotations should be removed.

private String command;

@JsonProperty("args")
private List<String> args = new ArrayList<>();

@JsonProperty("env")
private Map<String, String> env;

private ServerParameters(String command, List<String> args, Map<String, String> env) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2024-2024 the original author or authors.
* Copyright 2024-2026 the original author or authors.
*/

package io.modelcontextprotocol.server;
Expand Down Expand Up @@ -971,7 +971,8 @@ private McpRequestHandler<Object> setLoggerRequestHandler() {
private McpRequestHandler<McpSchema.CompleteResult> completionCompleteRequestHandler() {
return (exchange, params) -> {

McpSchema.CompleteRequest request = parseCompletionParams(params);
McpSchema.CompleteRequest request = jsonMapper.convertValue(params, new TypeRef<>() {
});

if (request.ref() == null) {
return Mono.error(
Expand Down Expand Up @@ -1072,50 +1073,6 @@ private McpRequestHandler<McpSchema.CompleteResult> completionCompleteRequestHan
};
}

/**
* Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest}
* object.
* <p>
* This method manually extracts the `ref` and `argument` fields from the input map,
* determines the correct reference type (either prompt or resource), and constructs a
* fully-typed {@code CompleteRequest} instance.
* @param object the raw request parameters, expected to be a Map containing "ref" and
* "argument" entries.
* @return a {@link McpSchema.CompleteRequest} representing the structured completion
* request.
* @throws IllegalArgumentException if the "ref" type is not recognized.
*/
@SuppressWarnings("unchecked")
private McpSchema.CompleteRequest parseCompletionParams(Object object) {
Map<String, Object> params = (Map<String, Object>) object;
Map<String, Object> refMap = (Map<String, Object>) params.get("ref");
Map<String, Object> argMap = (Map<String, Object>) params.get("argument");
Map<String, Object> contextMap = (Map<String, Object>) params.get("context");
Map<String, Object> meta = (Map<String, Object>) params.get("_meta");

String refType = (String) refMap.get("type");

McpSchema.CompleteReference ref = switch (refType) {
case PromptReference.TYPE -> new McpSchema.PromptReference(refType, (String) refMap.get("name"),
refMap.get("title") != null ? (String) refMap.get("title") : null);
case ResourceReference.TYPE -> new McpSchema.ResourceReference(refType, (String) refMap.get("uri"));
default -> throw new IllegalArgumentException("Invalid ref type: " + refType);
};

String argName = (String) argMap.get("name");
String argValue = (String) argMap.get("value");
McpSchema.CompleteRequest.CompleteArgument argument = new McpSchema.CompleteRequest.CompleteArgument(argName,
argValue);

McpSchema.CompleteRequest.CompleteContext context = null;
if (contextMap != null) {
Map<String, String> arguments = (Map<String, String>) contextMap.get("arguments");
context = new McpSchema.CompleteRequest.CompleteContext(arguments);
}

return new McpSchema.CompleteRequest(ref, argument, meta, context);
}

/**
* This method is package-private and used for test only. Should not be called by user
* code.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2024-2024 the original author or authors.
* Copyright 2024-2026 the original author or authors.
*/

package io.modelcontextprotocol.server;
Expand Down Expand Up @@ -715,7 +715,8 @@ private McpStatelessRequestHandler<McpSchema.GetPromptResult> promptsGetRequestH

private McpStatelessRequestHandler<McpSchema.CompleteResult> completionCompleteRequestHandler() {
return (ctx, params) -> {
McpSchema.CompleteRequest request = parseCompletionParams(params);
McpSchema.CompleteRequest request = jsonMapper.convertValue(params, new TypeRef<>() {
});

if (request.ref() == null) {
return Mono.error(
Expand Down Expand Up @@ -815,42 +816,6 @@ private McpStatelessRequestHandler<McpSchema.CompleteResult> completionCompleteR
};
}

/**
* Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest}
* object.
* <p>
* This method manually extracts the `ref` and `argument` fields from the input map,
* determines the correct reference type (either prompt or resource), and constructs a
* fully-typed {@code CompleteRequest} instance.
* @param object the raw request parameters, expected to be a Map containing "ref" and
* "argument" entries.
* @return a {@link McpSchema.CompleteRequest} representing the structured completion
* request.
* @throws IllegalArgumentException if the "ref" type is not recognized.
*/
@SuppressWarnings("unchecked")
private McpSchema.CompleteRequest parseCompletionParams(Object object) {
Map<String, Object> params = (Map<String, Object>) object;
Map<String, Object> refMap = (Map<String, Object>) params.get("ref");
Map<String, Object> argMap = (Map<String, Object>) params.get("argument");

String refType = (String) refMap.get("type");

McpSchema.CompleteReference ref = switch (refType) {
case PromptReference.TYPE -> new McpSchema.PromptReference(refType, (String) refMap.get("name"),
refMap.get("title") != null ? (String) refMap.get("title") : null);
case ResourceReference.TYPE -> new McpSchema.ResourceReference(refType, (String) refMap.get("uri"));
default -> throw new IllegalArgumentException("Invalid ref type: " + refType);
};

String argName = (String) argMap.get("name");
String argValue = (String) argMap.get("value");
McpSchema.CompleteRequest.CompleteArgument argument = new McpSchema.CompleteRequest.CompleteArgument(argName,
argValue);

return new McpSchema.CompleteRequest(ref, argument);
}

/**
* This method is package-private and used for test only. Should not be called by user
* code.
Expand Down
Loading
Loading