Skip to content

Commit 57abc3c

Browse files
committed
feat: add required-field builder constructors for MCP schema records
Deprecate no-arg builder() factories and Builder() constructors on CreateMessageRequest, ElicitRequest, and LoggingMessageNotification. Add new builder(required...) factories that validate required fields upfront. Add new builders for ProgressNotification and JSONRPCError. Null checks in private constructors and required-field setters ensure invalid state cannot be introduced at any point in the builder chain. Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com>
1 parent cb93d9e commit 57abc3c

8 files changed

Lines changed: 317 additions & 181 deletions

File tree

MIGRATION-2.0.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,9 @@ The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSO
7777
- Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map<String, Object>` (or copy into your own schema wrapper).
7878
- `Tool.Builder.inputSchema(JsonSchema)` remains as a **deprecated** helper that maps the old record into a map; prefer `inputSchema(Map)` or `inputSchema(McpJsonMapper, String)`.
7979

80-
### Required MCP spec fields are now guarded against `null` at construction time
80+
### Required MCP spec fields are enforced at construction time; builders require them upfront
8181

82-
The following records now assert that their required fields (as mandated by the MCP specification) are non-null at construction time. Passing `null` for any of these fields throws `IllegalArgumentException` immediately, rather than producing a structurally invalid object that fails later during serialization or protocol handling.
82+
The following records assert that their required fields are non-null at construction time. Passing `null` throws `IllegalArgumentException` immediately, rather than producing a structurally invalid object that fails later during serialization or protocol handling.
8383

8484
| Record | Required (non-null) fields |
8585
|--------|---------------------------|
@@ -91,9 +91,24 @@ The following records now assert that their required fields (as mandated by the
9191
| `ProgressNotification` | `progressToken`, `progress` |
9292
| `LoggingMessageNotification` | `level`, `data` |
9393

94-
**Action:** Audit any code that constructs these records with potentially-null values and provide valid, non-null arguments. Code paths that previously silently produced malformed wire messages will now fail fast at the construction site.
94+
**Action:** Audit any code that constructs these records with potentially-null values and provide valid, non-null arguments.
9595

96-
**Note on `LoggingMessageNotification.level`:** Because `LoggingLevel` deserialization is lenient (unknown strings produce `null` — see the section above), inbound notifications with an unrecognized level will fail to deserialize into a `LoggingMessageNotification`. Ensure clients and servers send only recognized level strings.
96+
#### Builder API changes
97+
98+
The builder factory methods for several records now require the mandatory fields as arguments, making it impossible to obtain a builder that is already missing required state. The old no-arg `builder()` factory and the public no-arg `Builder()` constructor are deprecated and will be removed in a future release.
99+
100+
| Type | Old (deprecated) | New |
101+
|------|-----------------|-----|
102+
| `CreateMessageRequest` | `CreateMessageRequest.builder().messages(m).maxTokens(n)` | `CreateMessageRequest.builder(m, n)` |
103+
| `ElicitRequest` | `ElicitRequest.builder().message(m).requestedSchema(s)` | `ElicitRequest.builder(m, s)` |
104+
| `LoggingMessageNotification` | `LoggingMessageNotification.builder().level(l).data(d)` | `LoggingMessageNotification.builder(l, d)` |
105+
106+
Two records that previously had no builder now have one with the same required-first convention:
107+
108+
- `ProgressNotification.builder(progressToken, progress)` — optional: `.total(Double)`, `.message(String)`, `.meta(Map)`
109+
- `JSONRPCResponse.JSONRPCError.builder(code, message)` — optional: `.data(Object)`
110+
111+
**Note:** `LoggingMessageNotification.level` must never be `null`. Because `LoggingLevel` deserialization is lenient (see the `LoggingLevel` section above), callers should ensure clients and servers send only recognized level strings.
97112

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

conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -237,17 +237,14 @@ private static List<McpServerFeatures.SyncToolSpecification> createToolSpecs() {
237237
.callHandler((exchange, request) -> {
238238
logger.info("Tool 'test_tool_with_logging' called");
239239
// Send log notifications
240-
exchange.loggingNotification(LoggingMessageNotification.builder()
241-
.level(LoggingLevel.INFO)
242-
.data("Tool execution started")
240+
exchange.loggingNotification(LoggingMessageNotification
241+
.builder(LoggingLevel.INFO, "Tool execution started")
243242
.build());
244-
exchange.loggingNotification(LoggingMessageNotification.builder()
245-
.level(LoggingLevel.INFO)
246-
.data("Tool processing data")
243+
exchange.loggingNotification(LoggingMessageNotification
244+
.builder(LoggingLevel.INFO, "Tool processing data")
247245
.build());
248-
exchange.loggingNotification(LoggingMessageNotification.builder()
249-
.level(LoggingLevel.INFO)
250-
.data("Tool execution completed")
246+
exchange.loggingNotification(LoggingMessageNotification
247+
.builder(LoggingLevel.INFO, "Tool execution completed")
251248
.build());
252249
return CallToolResult.builder()
253250
.content(List.of(new TextContent("Tool execution completed with logging")))
@@ -335,9 +332,8 @@ private static List<McpServerFeatures.SyncToolSpecification> createToolSpecs() {
335332
String prompt = (String) request.arguments().get("prompt");
336333

337334
// Request sampling from client
338-
CreateMessageRequest samplingRequest = CreateMessageRequest.builder()
339-
.messages(List.of(new SamplingMessage(Role.USER, new TextContent(prompt))))
340-
.maxTokens(100)
335+
CreateMessageRequest samplingRequest = CreateMessageRequest
336+
.builder(List.of(new SamplingMessage(Role.USER, new TextContent(prompt))), 100)
341337
.build();
342338

343339
CreateMessageResult response = exchange.createMessage(samplingRequest);

mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,36 @@ public record JSONRPCError( // @formatter:off
301301
Assert.notNull(code, "code must not be null");
302302
Assert.notNull(message, "message must not be null");
303303
}
304+
305+
public static Builder builder(int code, String message) {
306+
return new Builder(code, message);
307+
}
308+
309+
public static class Builder {
310+
311+
private final Integer code;
312+
313+
private final String message;
314+
315+
private Object data;
316+
317+
private Builder(int code, String message) {
318+
Assert.notNull(message, "message must not be null");
319+
this.code = code;
320+
this.message = message;
321+
}
322+
323+
public Builder data(Object data) {
324+
this.data = data;
325+
return this;
326+
}
327+
328+
public JSONRPCError build() {
329+
return new JSONRPCError(code, message, data);
330+
}
331+
332+
}
333+
304334
}
305335
}
306336

@@ -1599,6 +1629,14 @@ public static class Builder {
15991629

16001630
private Boolean isError = false;
16011631

1632+
/**
1633+
* @deprecated Use {@link CallToolResult#builder()} factory method instead of
1634+
* instantiating the builder directly.
1635+
*/
1636+
@Deprecated
1637+
public Builder() {
1638+
}
1639+
16021640
private Object structuredContent;
16031641

16041642
private Map<String, Object> meta;
@@ -1692,6 +1730,7 @@ public Builder meta(Map<String, Object> meta) {
16921730
* @return a new CallToolResult instance
16931731
*/
16941732
public CallToolResult build() {
1733+
Assert.notNull(content, "content must not be null");
16951734
return new CallToolResult(content, isError, structuredContent, meta);
16961735
}
16971736

@@ -1870,10 +1909,18 @@ public enum ContextInclusionStrategy {
18701909

18711910
}
18721911

1912+
/**
1913+
* @deprecated Use {@link #builder(List, int)} instead.
1914+
*/
1915+
@Deprecated
18731916
public static Builder builder() {
18741917
return new Builder();
18751918
}
18761919

1920+
public static Builder builder(List<SamplingMessage> messages, int maxTokens) {
1921+
return new Builder(messages, maxTokens);
1922+
}
1923+
18771924
public static class Builder {
18781925

18791926
private List<SamplingMessage> messages;
@@ -1894,7 +1941,22 @@ public static class Builder {
18941941

18951942
private Map<String, Object> meta;
18961943

1944+
/**
1945+
* @deprecated Use {@link CreateMessageRequest#builder(List, int)} factory
1946+
* method instead.
1947+
*/
1948+
@Deprecated
1949+
public Builder() {
1950+
}
1951+
1952+
private Builder(List<SamplingMessage> messages, int maxTokens) {
1953+
Assert.notNull(messages, "messages must not be null");
1954+
this.messages = messages;
1955+
this.maxTokens = maxTokens;
1956+
}
1957+
18971958
public Builder messages(List<SamplingMessage> messages) {
1959+
Assert.notNull(messages, "messages must not be null");
18981960
this.messages = messages;
18991961
return this;
19001962
}
@@ -2092,10 +2154,18 @@ public ElicitRequest(String message, Map<String, Object> requestedSchema) {
20922154
this(message, requestedSchema, null);
20932155
}
20942156

2157+
/**
2158+
* @deprecated Use {@link #builder(String, Map)} instead.
2159+
*/
2160+
@Deprecated
20952161
public static Builder builder() {
20962162
return new Builder();
20972163
}
20982164

2165+
public static Builder builder(String message, Map<String, Object> requestedSchema) {
2166+
return new Builder(message, requestedSchema);
2167+
}
2168+
20992169
public static class Builder {
21002170

21012171
private String message;
@@ -2104,12 +2174,29 @@ public static class Builder {
21042174

21052175
private Map<String, Object> meta;
21062176

2177+
/**
2178+
* @deprecated Use {@link ElicitRequest#builder(String, Map)} factory method
2179+
* instead.
2180+
*/
2181+
@Deprecated
2182+
public Builder() {
2183+
}
2184+
2185+
private Builder(String message, Map<String, Object> requestedSchema) {
2186+
Assert.notNull(message, "message must not be null");
2187+
Assert.notNull(requestedSchema, "requestedSchema must not be null");
2188+
this.message = message;
2189+
this.requestedSchema = requestedSchema;
2190+
}
2191+
21072192
public Builder message(String message) {
2193+
Assert.notNull(message, "message must not be null");
21082194
this.message = message;
21092195
return this;
21102196
}
21112197

21122198
public Builder requestedSchema(Map<String, Object> requestedSchema) {
2199+
Assert.notNull(requestedSchema, "requestedSchema must not be null");
21132200
this.requestedSchema = requestedSchema;
21142201
return this;
21152202
}
@@ -2271,6 +2358,50 @@ public record ProgressNotification( // @formatter:off
22712358
public ProgressNotification(Object progressToken, double progress, Double total, String message) {
22722359
this(progressToken, progress, total, message, null);
22732360
}
2361+
2362+
public static Builder builder(Object progressToken, double progress) {
2363+
return new Builder(progressToken, progress);
2364+
}
2365+
2366+
public static class Builder {
2367+
2368+
private final Object progressToken;
2369+
2370+
private final Double progress;
2371+
2372+
private Double total;
2373+
2374+
private String message;
2375+
2376+
private Map<String, Object> meta;
2377+
2378+
private Builder(Object progressToken, double progress) {
2379+
Assert.notNull(progressToken, "progressToken must not be null");
2380+
this.progressToken = progressToken;
2381+
this.progress = progress;
2382+
}
2383+
2384+
public Builder total(Double total) {
2385+
this.total = total;
2386+
return this;
2387+
}
2388+
2389+
public Builder message(String message) {
2390+
this.message = message;
2391+
return this;
2392+
}
2393+
2394+
public Builder meta(Map<String, Object> meta) {
2395+
this.meta = meta;
2396+
return this;
2397+
}
2398+
2399+
public ProgressNotification build() {
2400+
return new ProgressNotification(progressToken, progress, total, message, meta);
2401+
}
2402+
2403+
}
2404+
22742405
}
22752406

22762407
/**
@@ -2320,10 +2451,18 @@ public LoggingMessageNotification(LoggingLevel level, String logger, String data
23202451
this(level, logger, data, null);
23212452
}
23222453

2454+
/**
2455+
* @deprecated Use {@link #builder(LoggingLevel, String)} instead.
2456+
*/
2457+
@Deprecated
23232458
public static Builder builder() {
23242459
return new Builder();
23252460
}
23262461

2462+
public static Builder builder(LoggingLevel level, String data) {
2463+
return new Builder(level, data);
2464+
}
2465+
23272466
public static class Builder {
23282467

23292468
private LoggingLevel level = LoggingLevel.INFO;
@@ -2334,7 +2473,24 @@ public static class Builder {
23342473

23352474
private Map<String, Object> meta;
23362475

2476+
/**
2477+
* @deprecated Use
2478+
* {@link LoggingMessageNotification#builder(LoggingLevel, String)} factory
2479+
* method instead.
2480+
*/
2481+
@Deprecated
2482+
public Builder() {
2483+
}
2484+
2485+
private Builder(LoggingLevel level, String data) {
2486+
Assert.notNull(level, "level must not be null");
2487+
Assert.notNull(data, "data must not be null");
2488+
this.level = level;
2489+
this.data = data;
2490+
}
2491+
23372492
public Builder level(LoggingLevel level) {
2493+
Assert.notNull(level, "level must not be null");
23382494
this.level = level;
23392495
return this;
23402496
}
@@ -2345,6 +2501,7 @@ public Builder logger(String logger) {
23452501
}
23462502

23472503
public Builder data(String data) {
2504+
Assert.notNull(data, "data must not be null");
23482505
this.data = data;
23492506
return this;
23502507
}

0 commit comments

Comments
 (0)