From 47bf2aebe3b69e1a2abf3fc5587521f1589adccb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:06:46 +0200 Subject: [PATCH 1/9] feat!: guard required MCP spec fields against null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records in McpSchema whose fields the MCP spec marks as required were storing them as nullable Java types with no validation. A caller constructing one of these records with null for a required field would silently produce invalid wire JSON because @JsonInclude(NON_ABSENT) omits null values without complaint. Compact canonical constructors with Assert.notNull are added to seven records: - JSONRPCError (code, message), - ProgressNotification (progressToken, progress), - LoggingMessageNotification (level, data), - CreateMessageRequest (messages, maxTokens), - CallToolResult (content), - SamplingMessage (role, content), - ElicitRequest (message, requestedSchema). Test code that constructed these records without required fields was updated to supply valid values; those tests were testing capability-check or error-path logic that fires after construction, so the missing fields were incidental rather than intentional. Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../modelcontextprotocol/spec/McpSchema.java | 34 +++++++++++++++++++ .../server/McpAsyncServerExchangeTests.java | 11 ++++++ .../server/McpSyncServerExchangeTests.java | 11 ++++++ ...stractMcpClientServerIntegrationTests.java | 3 ++ .../spec/McpSchemaTests.java | 6 ++-- 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 72376929c..023466503 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -296,6 +296,11 @@ public record JSONRPCError( // @formatter:off @JsonProperty("code") Integer code, @JsonProperty("message") String message, @JsonProperty("data") Object data) { // @formatter:on + + public JSONRPCError { + Assert.notNull(code, "code must not be null"); + Assert.notNull(message, "message must not be null"); + } } } @@ -1573,6 +1578,10 @@ public record CallToolResult( // @formatter:off @JsonProperty("structuredContent") Object structuredContent, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public CallToolResult { + Assert.notNull(content, "content must not be null"); + } + /** * Creates a builder for {@link CallToolResult}. * @return a new builder instance @@ -1797,6 +1806,11 @@ public static ModelHint of(String name) { public record SamplingMessage( // @formatter:off @JsonProperty("role") Role role, @JsonProperty("content") Content content) { // @formatter:on + + public SamplingMessage { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + } } /** @@ -1834,6 +1848,11 @@ public record CreateMessageRequest( // @formatter:off @JsonProperty("metadata") Map metadata, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public CreateMessageRequest { + Assert.notNull(messages, "messages must not be null"); + Assert.notNull(maxTokens, "maxTokens must not be null"); + } + // backwards compatibility constructor public CreateMessageRequest(List messages, ModelPreferences modelPreferences, String systemPrompt, ContextInclusionStrategy includeContext, Double temperature, Integer maxTokens, @@ -2063,6 +2082,11 @@ public record ElicitRequest( // @formatter:off @JsonProperty("requestedSchema") Map requestedSchema, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public ElicitRequest { + Assert.notNull(message, "message must not be null"); + Assert.notNull(requestedSchema, "requestedSchema must not be null"); + } + // backwards compatibility constructor public ElicitRequest(String message, Map requestedSchema) { this(message, requestedSchema, null); @@ -2239,6 +2263,11 @@ public record ProgressNotification( // @formatter:off @JsonProperty("message") String message, @JsonProperty("_meta") Map meta) implements Notification { // @formatter:on + public ProgressNotification { + Assert.notNull(progressToken, "progressToken must not be null"); + Assert.notNull(progress, "progress must not be null"); + } + public ProgressNotification(Object progressToken, double progress, Double total, String message) { this(progressToken, progress, total, message, null); } @@ -2281,6 +2310,11 @@ public record LoggingMessageNotification( // @formatter:off @JsonProperty("data") String data, @JsonProperty("_meta") Map meta) implements Notification { // @formatter:on + public LoggingMessageNotification { + Assert.notNull(level, "level must not be null"); + Assert.notNull(data, "data must not be null"); + } + // backwards compatibility constructor public LoggingMessageNotification(LoggingLevel level, String logger, String data) { this(level, logger, data, null); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java index e6161a59f..d4c8c1f2e 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java @@ -306,6 +306,7 @@ void testCreateElicitationWithNullCapabilities() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your name") + .requestedSchema(Map.of("type", "object")) .build(); StepVerifier.create(exchangeWithNullCapabilities.createElicitation(elicitRequest)) @@ -330,6 +331,7 @@ void testCreateElicitationWithoutElicitationCapabilities() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your name") + .requestedSchema(Map.of("type", "object")) .build(); StepVerifier.create(exchangeWithoutElicitation.createElicitation(elicitRequest)).verifyErrorSatisfies(error -> { @@ -397,6 +399,7 @@ void testCreateElicitationWithDeclineAction() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide sensitive information") + .requestedSchema(Map.of("type", "object")) .build(); McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() @@ -424,6 +427,7 @@ void testCreateElicitationWithCancelAction() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your information") + .requestedSchema(Map.of("type", "object")) .build(); McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() @@ -451,6 +455,7 @@ void testCreateElicitationWithSessionError() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your name") + .requestedSchema(Map.of("type", "object")) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -474,6 +479,7 @@ void testCreateMessageWithNullCapabilities() { McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + .maxTokens(1000) .build(); StepVerifier.create(exchangeWithNullCapabilities.createMessage(createMessageRequest)) @@ -500,6 +506,7 @@ void testCreateMessageWithoutSamplingCapabilities() { McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + .maxTokens(1000) .build(); StepVerifier.create(exchangeWithoutSampling.createMessage(createMessageRequest)).verifyErrorSatisfies(error -> { @@ -525,6 +532,7 @@ void testCreateMessageWithBasicRequest() { McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + .maxTokens(1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() @@ -563,6 +571,7 @@ void testCreateMessageWithImageContent() { .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.ImageContent(null, "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", "image/jpeg")))) + .maxTokens(1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() @@ -596,6 +605,7 @@ void testCreateMessageWithSessionError() { McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello")))) + .maxTokens(1000) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE), eq(createMessageRequest), @@ -621,6 +631,7 @@ void testCreateMessageWithIncludeContext() { .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("What files are available?")))) .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.ALL_SERVERS) + .maxTokens(1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java index fba733c9a..d7354cbc5 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java @@ -303,6 +303,7 @@ void testCreateElicitationWithNullCapabilities() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your name") + .requestedSchema(Map.of("type", "object")) .build(); assertThatThrownBy(() -> exchangeWithNullCapabilities.createElicitation(elicitRequest)) @@ -326,6 +327,7 @@ void testCreateElicitationWithoutElicitationCapabilities() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your name") + .requestedSchema(Map.of("type", "object")) .build(); assertThatThrownBy(() -> exchangeWithoutElicitation.createElicitation(elicitRequest)) @@ -394,6 +396,7 @@ void testCreateElicitationWithDeclineAction() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide sensitive information") + .requestedSchema(Map.of("type", "object")) .build(); McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() @@ -422,6 +425,7 @@ void testCreateElicitationWithCancelAction() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your information") + .requestedSchema(Map.of("type", "object")) .build(); McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() @@ -450,6 +454,7 @@ void testCreateElicitationWithSessionError() { McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() .message("Please provide your name") + .requestedSchema(Map.of("type", "object")) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -475,6 +480,7 @@ void testCreateMessageWithNullCapabilities() { McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + .maxTokens(1000) .build(); assertThatThrownBy(() -> exchangeWithNullCapabilities.createMessage(createMessageRequest)) @@ -500,6 +506,7 @@ void testCreateMessageWithoutSamplingCapabilities() { McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + .maxTokens(1000) .build(); assertThatThrownBy(() -> exchangeWithoutSampling.createMessage(createMessageRequest)) @@ -525,6 +532,7 @@ void testCreateMessageWithBasicRequest() { McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) + .maxTokens(1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() @@ -564,6 +572,7 @@ void testCreateMessageWithImageContent() { .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.ImageContent(null, "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", "image/jpeg")))) + .maxTokens(1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() @@ -598,6 +607,7 @@ void testCreateMessageWithSessionError() { McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() .messages(Arrays .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello")))) + .maxTokens(1000) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE), eq(createMessageRequest), @@ -624,6 +634,7 @@ void testCreateMessageWithIncludeContext() { .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("What files are available?")))) .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.ALL_SERVERS) + .maxTokens(1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index beec006ba..9ee6795ba 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -171,6 +171,7 @@ void testCreateMessageSuccess(String clientType) { .speedPriority(1.0) .intelligencePriority(1.0) .build()) + .maxTokens(1000) .build(); return exchange.createMessage(createMessageRequest) @@ -250,6 +251,7 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr .speedPriority(1.0) .intelligencePriority(1.0) .build()) + .maxTokens(1000) .build(); return exchange.createMessage(createMessageRequest) @@ -325,6 +327,7 @@ void testCreateMessageWithRequestTimeoutFail(String clientType) throws Interrupt .speedPriority(1.0) .intelligencePriority(1.0) .build()) + .maxTokens(1000) .build(); return exchange.createMessage(createMessageRequest).thenReturn(callResponse); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 09529f2e0..0a8760416 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1427,6 +1427,7 @@ void testCreateMessageResultUnknownStopReason() throws Exception { @Test void testCreateElicitationRequest() throws Exception { McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder() + .message("Please provide additional information") .requestedSchema(Map.of("type", "object", "required", List.of("a"), "properties", Map.of("foo", Map.of("type", "string")))) .build(); @@ -1436,8 +1437,9 @@ void testCreateElicitationRequest() throws Exception { assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) .when(Option.IGNORING_EXTRA_ARRAY_ITEMS) .isObject() - .isEqualTo(json(""" - {"requestedSchema":{"properties":{"foo":{"type":"string"}},"required":["a"],"type":"object"}}""")); + .isEqualTo( + json(""" + {"message":"Please provide additional information","requestedSchema":{"properties":{"foo":{"type":"string"}},"required":["a"],"type":"object"}}""")); } @Test From cb93d9e5e37c2cd3845a5f1de624516faceb7a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Mon, 20 Apr 2026 19:31:54 +0200 Subject: [PATCH 2/9] Add migration notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- MIGRATION-2.0.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index 273421189..a2d53a1b2 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -77,6 +77,24 @@ The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSO - Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map` (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)`. +### Required MCP spec fields are now guarded against `null` at construction time + +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. + +| Record | Required (non-null) fields | +|--------|---------------------------| +| `JSONRPCResponse.JSONRPCError` | `code`, `message` | +| `CallToolResult` | `content` | +| `SamplingMessage` | `role`, `content` | +| `CreateMessageRequest` | `messages`, `maxTokens` | +| `ElicitRequest` | `message`, `requestedSchema` | +| `ProgressNotification` | `progressToken`, `progress` | +| `LoggingMessageNotification` | `level`, `data` | + +**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. + +**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. + ### 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. From 57abc3c028742239e0031710e410001401720144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Tue, 21 Apr 2026 00:21:23 +0200 Subject: [PATCH 3/9] feat: add required-field builder constructors for MCP schema records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- MIGRATION-2.0.md | 23 ++- .../server/ConformanceServlet.java | 20 +-- .../modelcontextprotocol/spec/McpSchema.java | 157 ++++++++++++++++++ .../server/McpAsyncServerExchangeTests.java | 102 ++++++------ .../server/McpSyncServerExchangeTests.java | 102 ++++++------ ...stractMcpClientServerIntegrationTests.java | 61 +++---- .../McpAsyncClientResponseHandlerTests.java | 12 +- .../spec/McpSchemaTests.java | 21 +-- 8 files changed, 317 insertions(+), 181 deletions(-) diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index a2d53a1b2..261670768 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -77,9 +77,9 @@ The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSO - Java code that used `Tool.inputSchema()` as a `JsonSchema` must switch to `Map` (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)`. -### Required MCP spec fields are now guarded against `null` at construction time +### Required MCP spec fields are enforced at construction time; builders require them upfront -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. +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. | Record | Required (non-null) fields | |--------|---------------------------| @@ -91,9 +91,24 @@ The following records now assert that their required fields (as mandated by the | `ProgressNotification` | `progressToken`, `progress` | | `LoggingMessageNotification` | `level`, `data` | -**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. +**Action:** Audit any code that constructs these records with potentially-null values and provide valid, non-null arguments. -**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. +#### Builder API changes + +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. + +| Type | Old (deprecated) | New | +|------|-----------------|-----| +| `CreateMessageRequest` | `CreateMessageRequest.builder().messages(m).maxTokens(n)` | `CreateMessageRequest.builder(m, n)` | +| `ElicitRequest` | `ElicitRequest.builder().message(m).requestedSchema(s)` | `ElicitRequest.builder(m, s)` | +| `LoggingMessageNotification` | `LoggingMessageNotification.builder().level(l).data(d)` | `LoggingMessageNotification.builder(l, d)` | + +Two records that previously had no builder now have one with the same required-first convention: + +- `ProgressNotification.builder(progressToken, progress)` — optional: `.total(Double)`, `.message(String)`, `.meta(Map)` +- `JSONRPCResponse.JSONRPCError.builder(code, message)` — optional: `.data(Object)` + +**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. ### Optional JSON Schema validation on `tools/call` (server) diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java index 25ec2c106..88b31402c 100644 --- a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -237,17 +237,14 @@ private static List createToolSpecs() { .callHandler((exchange, request) -> { logger.info("Tool 'test_tool_with_logging' called"); // Send log notifications - exchange.loggingNotification(LoggingMessageNotification.builder() - .level(LoggingLevel.INFO) - .data("Tool execution started") + exchange.loggingNotification(LoggingMessageNotification + .builder(LoggingLevel.INFO, "Tool execution started") .build()); - exchange.loggingNotification(LoggingMessageNotification.builder() - .level(LoggingLevel.INFO) - .data("Tool processing data") + exchange.loggingNotification(LoggingMessageNotification + .builder(LoggingLevel.INFO, "Tool processing data") .build()); - exchange.loggingNotification(LoggingMessageNotification.builder() - .level(LoggingLevel.INFO) - .data("Tool execution completed") + exchange.loggingNotification(LoggingMessageNotification + .builder(LoggingLevel.INFO, "Tool execution completed") .build()); return CallToolResult.builder() .content(List.of(new TextContent("Tool execution completed with logging"))) @@ -335,9 +332,8 @@ private static List createToolSpecs() { String prompt = (String) request.arguments().get("prompt"); // Request sampling from client - CreateMessageRequest samplingRequest = CreateMessageRequest.builder() - .messages(List.of(new SamplingMessage(Role.USER, new TextContent(prompt)))) - .maxTokens(100) + CreateMessageRequest samplingRequest = CreateMessageRequest + .builder(List.of(new SamplingMessage(Role.USER, new TextContent(prompt))), 100) .build(); CreateMessageResult response = exchange.createMessage(samplingRequest); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 023466503..abd6e0ff9 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -301,6 +301,36 @@ public record JSONRPCError( // @formatter:off Assert.notNull(code, "code must not be null"); Assert.notNull(message, "message must not be null"); } + + public static Builder builder(int code, String message) { + return new Builder(code, message); + } + + public static class Builder { + + private final Integer code; + + private final String message; + + private Object data; + + private Builder(int code, String message) { + Assert.notNull(message, "message must not be null"); + this.code = code; + this.message = message; + } + + public Builder data(Object data) { + this.data = data; + return this; + } + + public JSONRPCError build() { + return new JSONRPCError(code, message, data); + } + + } + } } @@ -1599,6 +1629,14 @@ public static class Builder { private Boolean isError = false; + /** + * @deprecated Use {@link CallToolResult#builder()} factory method instead of + * instantiating the builder directly. + */ + @Deprecated + public Builder() { + } + private Object structuredContent; private Map meta; @@ -1692,6 +1730,7 @@ public Builder meta(Map meta) { * @return a new CallToolResult instance */ public CallToolResult build() { + Assert.notNull(content, "content must not be null"); return new CallToolResult(content, isError, structuredContent, meta); } @@ -1870,10 +1909,18 @@ public enum ContextInclusionStrategy { } + /** + * @deprecated Use {@link #builder(List, int)} instead. + */ + @Deprecated public static Builder builder() { return new Builder(); } + public static Builder builder(List messages, int maxTokens) { + return new Builder(messages, maxTokens); + } + public static class Builder { private List messages; @@ -1894,7 +1941,22 @@ public static class Builder { private Map meta; + /** + * @deprecated Use {@link CreateMessageRequest#builder(List, int)} factory + * method instead. + */ + @Deprecated + public Builder() { + } + + private Builder(List messages, int maxTokens) { + Assert.notNull(messages, "messages must not be null"); + this.messages = messages; + this.maxTokens = maxTokens; + } + public Builder messages(List messages) { + Assert.notNull(messages, "messages must not be null"); this.messages = messages; return this; } @@ -2092,10 +2154,18 @@ public ElicitRequest(String message, Map requestedSchema) { this(message, requestedSchema, null); } + /** + * @deprecated Use {@link #builder(String, Map)} instead. + */ + @Deprecated public static Builder builder() { return new Builder(); } + public static Builder builder(String message, Map requestedSchema) { + return new Builder(message, requestedSchema); + } + public static class Builder { private String message; @@ -2104,12 +2174,29 @@ public static class Builder { private Map meta; + /** + * @deprecated Use {@link ElicitRequest#builder(String, Map)} factory method + * instead. + */ + @Deprecated + public Builder() { + } + + private Builder(String message, Map requestedSchema) { + Assert.notNull(message, "message must not be null"); + Assert.notNull(requestedSchema, "requestedSchema must not be null"); + this.message = message; + this.requestedSchema = requestedSchema; + } + public Builder message(String message) { + Assert.notNull(message, "message must not be null"); this.message = message; return this; } public Builder requestedSchema(Map requestedSchema) { + Assert.notNull(requestedSchema, "requestedSchema must not be null"); this.requestedSchema = requestedSchema; return this; } @@ -2271,6 +2358,50 @@ public record ProgressNotification( // @formatter:off public ProgressNotification(Object progressToken, double progress, Double total, String message) { this(progressToken, progress, total, message, null); } + + public static Builder builder(Object progressToken, double progress) { + return new Builder(progressToken, progress); + } + + public static class Builder { + + private final Object progressToken; + + private final Double progress; + + private Double total; + + private String message; + + private Map meta; + + private Builder(Object progressToken, double progress) { + Assert.notNull(progressToken, "progressToken must not be null"); + this.progressToken = progressToken; + this.progress = progress; + } + + public Builder total(Double total) { + this.total = total; + return this; + } + + public Builder message(String message) { + this.message = message; + return this; + } + + public Builder meta(Map meta) { + this.meta = meta; + return this; + } + + public ProgressNotification build() { + return new ProgressNotification(progressToken, progress, total, message, meta); + } + + } + } /** @@ -2320,10 +2451,18 @@ public LoggingMessageNotification(LoggingLevel level, String logger, String data this(level, logger, data, null); } + /** + * @deprecated Use {@link #builder(LoggingLevel, String)} instead. + */ + @Deprecated public static Builder builder() { return new Builder(); } + public static Builder builder(LoggingLevel level, String data) { + return new Builder(level, data); + } + public static class Builder { private LoggingLevel level = LoggingLevel.INFO; @@ -2334,7 +2473,24 @@ public static class Builder { private Map meta; + /** + * @deprecated Use + * {@link LoggingMessageNotification#builder(LoggingLevel, String)} factory + * method instead. + */ + @Deprecated + public Builder() { + } + + private Builder(LoggingLevel level, String data) { + Assert.notNull(level, "level must not be null"); + Assert.notNull(data, "data must not be null"); + this.level = level; + this.data = data; + } + public Builder level(LoggingLevel level) { + Assert.notNull(level, "level must not be null"); this.level = level; return this; } @@ -2345,6 +2501,7 @@ public Builder logger(String logger) { } public Builder data(String data) { + Assert.notNull(data, "data must not be null"); this.data = data; return this; } diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java index d4c8c1f2e..5f07b318c 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java @@ -227,10 +227,9 @@ void testSetMinLoggingLevelWithNullValue() { @Test void testLoggingNotificationWithAllowedLevel() { - McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Test error message") .logger("test-logger") - .data("Test error message") .build(); when(mockSession.isNotificationForLevelAllowed(any())).thenReturn(Boolean.TRUE); @@ -248,10 +247,9 @@ void testLoggingNotificationWithFilteredLevel() { exchange.setMinLoggingLevel(McpSchema.LoggingLevel.DEBUG); verify(mockSession, times(1)).setMinLoggingLevel(eq(McpSchema.LoggingLevel.DEBUG)); - McpSchema.LoggingMessageNotification debugNotification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.DEBUG) + McpSchema.LoggingMessageNotification debugNotification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.DEBUG, "Debug message that should be filtered") .logger("test-logger") - .data("Debug message that should be filtered") .build(); when(mockSession.isNotificationForLevelAllowed(eq(McpSchema.LoggingLevel.DEBUG))).thenReturn(Boolean.TRUE); @@ -264,10 +262,9 @@ void testLoggingNotificationWithFilteredLevel() { verify(mockSession, times(1)).sendNotification(eq(McpSchema.METHOD_NOTIFICATION_MESSAGE), eq(debugNotification)); - McpSchema.LoggingMessageNotification warningNotification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.WARNING) + McpSchema.LoggingMessageNotification warningNotification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.WARNING, "Debug message that should be filtered") .logger("test-logger") - .data("Debug message that should be filtered") .build(); StepVerifier.create(exchange.loggingNotification(warningNotification)).verifyComplete(); @@ -279,10 +276,9 @@ void testLoggingNotificationWithFilteredLevel() { @Test void testLoggingNotificationWithSessionError() { - McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Test error message") .logger("test-logger") - .data("Test error message") .build(); when(mockSession.isNotificationForLevelAllowed(any())).thenReturn(Boolean.TRUE); @@ -304,9 +300,8 @@ void testCreateElicitationWithNullCapabilities() { McpAsyncServerExchange exchangeWithNullCapabilities = new McpAsyncServerExchange("testSessionId", mockSession, null, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); StepVerifier.create(exchangeWithNullCapabilities.createElicitation(elicitRequest)) @@ -329,9 +324,8 @@ void testCreateElicitationWithoutElicitationCapabilities() { McpAsyncServerExchange exchangeWithoutElicitation = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithoutElicitation, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); StepVerifier.create(exchangeWithoutElicitation.createElicitation(elicitRequest)).verifyErrorSatisfies(error -> { @@ -361,9 +355,8 @@ void testCreateElicitationWithComplexRequest() { java.util.Map.of("type", "number"))); requestedSchema.put("required", java.util.List.of("name")); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your personal information") - .requestedSchema(requestedSchema) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your personal information", requestedSchema) .build(); java.util.Map responseContent = new java.util.HashMap<>(); @@ -397,9 +390,8 @@ void testCreateElicitationWithDeclineAction() { McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide sensitive information") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide sensitive information", Map.of("type", "object")) .build(); McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() @@ -425,9 +417,8 @@ void testCreateElicitationWithCancelAction() { McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your information") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your information", Map.of("type", "object")) .build(); McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() @@ -453,9 +444,8 @@ void testCreateElicitationWithSessionError() { McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -476,10 +466,10 @@ void testCreateMessageWithNullCapabilities() { McpAsyncServerExchange exchangeWithNullCapabilities = new McpAsyncServerExchange("testSessionId", mockSession, null, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!"))), + 1000) .build(); StepVerifier.create(exchangeWithNullCapabilities.createMessage(createMessageRequest)) @@ -503,10 +493,10 @@ void testCreateMessageWithoutSamplingCapabilities() { McpAsyncServerExchange exchangeWithoutSampling = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithoutSampling, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!"))), + 1000) .build(); StepVerifier.create(exchangeWithoutSampling.createMessage(createMessageRequest)).verifyErrorSatisfies(error -> { @@ -529,10 +519,10 @@ void testCreateMessageWithBasicRequest() { McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!"))), + 1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() @@ -567,11 +557,13 @@ void testCreateMessageWithImageContent() { capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); // Create request with image content - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.ImageContent(null, "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", - "image/jpeg")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder( + Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.ImageContent(null, + "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", "image/jpeg"))), + 1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() @@ -602,10 +594,11 @@ void testCreateMessageWithSessionError() { McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder( + Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello"))), + 1000) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE), eq(createMessageRequest), @@ -627,11 +620,10 @@ void testCreateMessageWithIncludeContext() { McpAsyncServerExchange exchangeWithSampling = new McpAsyncServerExchange("testSessionId", mockSession, capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("What files are available?")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("What files are available?"))), 1000) .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.ALL_SERVERS) - .maxTokens(1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java index d7354cbc5..009a17955 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpSyncServerExchangeTests.java @@ -221,10 +221,9 @@ void testLoggingNotificationWithNullMessage() { @Test void testLoggingNotificationWithAllowedLevel() { - McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Test error message") .logger("test-logger") - .data("Test error message") .build(); when(mockSession.isNotificationForLevelAllowed(any())).thenReturn(Boolean.TRUE); @@ -243,10 +242,9 @@ void testLoggingNotificationWithFilteredLevel() { asyncExchange.setMinLoggingLevel(McpSchema.LoggingLevel.DEBUG); verify(mockSession, times(1)).setMinLoggingLevel(McpSchema.LoggingLevel.DEBUG); - McpSchema.LoggingMessageNotification debugNotification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.DEBUG) + McpSchema.LoggingMessageNotification debugNotification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.DEBUG, "Debug message that should be filtered") .logger("test-logger") - .data("Debug message that should be filtered") .build(); when(mockSession.isNotificationForLevelAllowed(eq(McpSchema.LoggingLevel.DEBUG))).thenReturn(Boolean.TRUE); @@ -259,10 +257,9 @@ void testLoggingNotificationWithFilteredLevel() { verify(mockSession, times(1)).sendNotification(eq(McpSchema.METHOD_NOTIFICATION_MESSAGE), eq(debugNotification)); - McpSchema.LoggingMessageNotification warningNotification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.WARNING) + McpSchema.LoggingMessageNotification warningNotification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.WARNING, "Debug message that should be filtered") .logger("test-logger") - .data("Debug message that should be filtered") .build(); exchange.loggingNotification(warningNotification); @@ -275,10 +272,9 @@ void testLoggingNotificationWithFilteredLevel() { @Test void testLoggingNotificationWithSessionError() { - McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + McpSchema.LoggingMessageNotification notification = McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Test error message") .logger("test-logger") - .data("Test error message") .build(); when(mockSession.isNotificationForLevelAllowed(any())).thenReturn(Boolean.TRUE); @@ -301,9 +297,8 @@ void testCreateElicitationWithNullCapabilities() { McpSyncServerExchange exchangeWithNullCapabilities = new McpSyncServerExchange( asyncExchangeWithNullCapabilities); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); assertThatThrownBy(() -> exchangeWithNullCapabilities.createElicitation(elicitRequest)) @@ -325,9 +320,8 @@ void testCreateElicitationWithoutElicitationCapabilities() { mockSession, capabilitiesWithoutElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithoutElicitation = new McpSyncServerExchange(asyncExchangeWithoutElicitation); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); assertThatThrownBy(() -> exchangeWithoutElicitation.createElicitation(elicitRequest)) @@ -357,9 +351,8 @@ void testCreateElicitationWithComplexRequest() { java.util.Map.of("type", "number"))); requestedSchema.put("required", java.util.List.of("name")); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your personal information") - .requestedSchema(requestedSchema) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your personal information", requestedSchema) .build(); java.util.Map responseContent = new java.util.HashMap<>(); @@ -394,9 +387,8 @@ void testCreateElicitationWithDeclineAction() { capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide sensitive information") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide sensitive information", Map.of("type", "object")) .build(); McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() @@ -423,9 +415,8 @@ void testCreateElicitationWithCancelAction() { capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your information") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your information", Map.of("type", "object")) .build(); McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder() @@ -452,9 +443,8 @@ void testCreateElicitationWithSessionError() { capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithElicitation = new McpSyncServerExchange(asyncExchangeWithElicitation); - McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest.builder() - .message("Please provide your name") - .requestedSchema(Map.of("type", "object")) + McpSchema.ElicitRequest elicitRequest = McpSchema.ElicitRequest + .builder("Please provide your name", Map.of("type", "object")) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitRequest), any(TypeRef.class))) @@ -477,10 +467,10 @@ void testCreateMessageWithNullCapabilities() { McpSyncServerExchange exchangeWithNullCapabilities = new McpSyncServerExchange( asyncExchangeWithNullCapabilities); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!"))), + 1000) .build(); assertThatThrownBy(() -> exchangeWithNullCapabilities.createMessage(createMessageRequest)) @@ -503,10 +493,10 @@ void testCreateMessageWithoutSamplingCapabilities() { capabilitiesWithoutSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithoutSampling = new McpSyncServerExchange(asyncExchangeWithoutSampling); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!"))), + 1000) .build(); assertThatThrownBy(() -> exchangeWithoutSampling.createMessage(createMessageRequest)) @@ -529,10 +519,10 @@ void testCreateMessageWithBasicRequest() { capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello, world!"))), + 1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() @@ -568,11 +558,13 @@ void testCreateMessageWithImageContent() { McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); // Create request with image content - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.ImageContent(null, "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", - "image/jpeg")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder( + Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.ImageContent(null, + "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD...", "image/jpeg"))), + 1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() @@ -604,10 +596,11 @@ void testCreateMessageWithSessionError() { capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays - .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello")))) - .maxTokens(1000) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder( + Arrays + .asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, new McpSchema.TextContent("Hello"))), + 1000) .build(); when(mockSession.sendRequest(eq(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE), eq(createMessageRequest), @@ -630,11 +623,10 @@ void testCreateMessageWithIncludeContext() { capabilitiesWithSampling, clientInfo, McpTransportContext.EMPTY); McpSyncServerExchange exchangeWithSampling = new McpSyncServerExchange(asyncExchangeWithSampling); - McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("What files are available?")))) + McpSchema.CreateMessageRequest createMessageRequest = McpSchema.CreateMessageRequest + .builder(Arrays.asList(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("What files are available?"))), 1000) .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.ALL_SERVERS) - .maxTokens(1000) .build(); McpSchema.CreateMessageResult expectedResult = McpSchema.CreateMessageResult.builder() diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index 9ee6795ba..7675f4e36 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -162,16 +162,15 @@ void testCreateMessageSuccess(String clientType) { .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) + var createMessageRequest = McpSchema.CreateMessageRequest + .builder(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Test message"))), 1000) .modelPreferences(ModelPreferences.builder() .hints(List.of()) .costPriority(1.0) .speedPriority(1.0) .intelligencePriority(1.0) .build()) - .maxTokens(1000) .build(); return exchange.createMessage(createMessageRequest) @@ -242,16 +241,15 @@ void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws Interr .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) + var createMessageRequest = McpSchema.CreateMessageRequest + .builder(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Test message"))), 1000) .modelPreferences(ModelPreferences.builder() .hints(List.of()) .costPriority(1.0) .speedPriority(1.0) .intelligencePriority(1.0) .build()) - .maxTokens(1000) .build(); return exchange.createMessage(createMessageRequest) @@ -318,16 +316,15 @@ void testCreateMessageWithRequestTimeoutFail(String clientType) throws Interrupt .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var createMessageRequest = McpSchema.CreateMessageRequest.builder() - .messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, - new McpSchema.TextContent("Test message")))) + var createMessageRequest = McpSchema.CreateMessageRequest + .builder(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER, + new McpSchema.TextContent("Test message"))), 1000) .modelPreferences(ModelPreferences.builder() .hints(List.of()) .costPriority(1.0) .speedPriority(1.0) .intelligencePriority(1.0) .build()) - .maxTokens(1000) .build(); return exchange.createMessage(createMessageRequest).thenReturn(callResponse); @@ -415,9 +412,8 @@ void testCreateElicitationSuccess(String clientType) { .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema( + var elicitationRequest = McpSchema.ElicitRequest + .builder("Test message", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); @@ -474,9 +470,8 @@ void testCreateElicitationWithRequestTimeoutSuccess(String clientType) { .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var elicitationRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema( + var elicitationRequest = McpSchema.ElicitRequest + .builder("Test message", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); @@ -545,9 +540,8 @@ void testCreateElicitationWithRequestTimeoutFail(String clientType) { .tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build()) .callHandler((exchange, request) -> { - var elicitationRequest = ElicitRequest.builder() - .message("Test message") - .requestedSchema( + var elicitationRequest = ElicitRequest + .builder("Test message", Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); @@ -1116,34 +1110,29 @@ void testLoggingNotification(String clientType) throws InterruptedException { //@formatter:off return exchange // This should be filtered out (DEBUG < NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.DEBUG) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.DEBUG, "Debug message") .logger("test-logger") - .data("Debug message") .build()) .then(exchange // This should be sent (NOTICE >= NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.NOTICE) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.NOTICE, "Notice message") .logger("test-logger") - .data("Notice message") .build())) .then(exchange // This should be sent (ERROR > NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Error message") .logger("test-logger") - .data("Error message") .build())) .then(exchange // This should be filtered out (INFO < NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.INFO) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.INFO, "Another info message") .logger("test-logger") - .data("Another info message") .build())) .then(exchange // This should be sent (ERROR >= NOTICE) - .loggingNotification(McpSchema.LoggingMessageNotification.builder() - .level(McpSchema.LoggingLevel.ERROR) + .loggingNotification(McpSchema.LoggingMessageNotification + .builder(McpSchema.LoggingLevel.ERROR, "Another error message") .logger("test-logger") - .data("Another error message") .build())) .thenReturn(CallToolResult.builder() .content(List.of(new McpSchema.TextContent("Logging test completed"))) diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java index 47a229afd..3692c1c4e 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientResponseHandlerTests.java @@ -417,9 +417,9 @@ void testElicitationCreateRequestHandling() { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock elicitation - var elicitRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema(Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) + var elicitRequest = McpSchema.ElicitRequest + .builder("Test message", + Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); // Simulate incoming request @@ -462,9 +462,9 @@ void testElicitationFailRequestHandling(McpSchema.ElicitResult.Action action) { assertThat(asyncMcpClient.initialize().block()).isNotNull(); // Create a mock elicitation - var elicitRequest = McpSchema.ElicitRequest.builder() - .message("Test message") - .requestedSchema(Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) + var elicitRequest = McpSchema.ElicitRequest + .builder("Test message", + Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string")))) .build(); // Simulate incoming request diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 0a8760416..9cc9f1e11 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -133,13 +133,12 @@ void testCreateMessageRequestWithMeta() throws Exception { Map meta = new HashMap<>(); meta.put("progressToken", "create-message-token-456"); - McpSchema.CreateMessageRequest request = McpSchema.CreateMessageRequest.builder() - .messages(Collections.singletonList(message)) + McpSchema.CreateMessageRequest request = McpSchema.CreateMessageRequest + .builder(Collections.singletonList(message), 1000) .modelPreferences(preferences) .systemPrompt("You are a helpful assistant") .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.THIS_SERVER) .temperature(0.7) - .maxTokens(1000) .stopSequences(Arrays.asList("STOP", "END")) .metadata(metadata) .meta(meta) @@ -1363,13 +1362,12 @@ void testCreateMessageRequest() throws Exception { Map metadata = new HashMap<>(); metadata.put("session", "test-session"); - McpSchema.CreateMessageRequest request = McpSchema.CreateMessageRequest.builder() - .messages(Collections.singletonList(message)) + McpSchema.CreateMessageRequest request = McpSchema.CreateMessageRequest + .builder(Collections.singletonList(message), 1000) .modelPreferences(preferences) .systemPrompt("You are a helpful assistant") .includeContext(McpSchema.CreateMessageRequest.ContextInclusionStrategy.THIS_SERVER) .temperature(0.7) - .maxTokens(1000) .stopSequences(Arrays.asList("STOP", "END")) .metadata(metadata) .build(); @@ -1426,10 +1424,9 @@ void testCreateMessageResultUnknownStopReason() throws Exception { @Test void testCreateElicitationRequest() throws Exception { - McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder() - .message("Please provide additional information") - .requestedSchema(Map.of("type", "object", "required", List.of("a"), "properties", - Map.of("foo", Map.of("type", "string")))) + McpSchema.ElicitRequest request = McpSchema.ElicitRequest + .builder("Please provide additional information", Map.of("type", "object", "required", List.of("a"), + "properties", Map.of("foo", Map.of("type", "string")))) .build(); String value = JSON_MAPPER.writeValueAsString(request); @@ -1466,9 +1463,7 @@ void testElicitRequestWithMeta() throws Exception { Map meta = new HashMap<>(); meta.put("progressToken", "elicit-token-789"); - McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder() - .message("Please provide your name") - .requestedSchema(requestedSchema) + McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder("Please provide your name", requestedSchema) .meta(meta) .build(); From 629f4df03d3156c2d2663475e3ff9a1a08d90164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Tue, 21 Apr 2026 07:13:30 +0200 Subject: [PATCH 4/9] Fix formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../conformance/server/ConformanceServlet.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java index 88b31402c..71704e2a7 100644 --- a/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java +++ b/conformance-tests/server-servlet/src/main/java/io/modelcontextprotocol/conformance/server/ConformanceServlet.java @@ -237,15 +237,14 @@ private static List createToolSpecs() { .callHandler((exchange, request) -> { logger.info("Tool 'test_tool_with_logging' called"); // Send log notifications - exchange.loggingNotification(LoggingMessageNotification - .builder(LoggingLevel.INFO, "Tool execution started") - .build()); - exchange.loggingNotification(LoggingMessageNotification - .builder(LoggingLevel.INFO, "Tool processing data") - .build()); - exchange.loggingNotification(LoggingMessageNotification - .builder(LoggingLevel.INFO, "Tool execution completed") - .build()); + exchange.loggingNotification( + LoggingMessageNotification.builder(LoggingLevel.INFO, "Tool execution started") + .build()); + exchange.loggingNotification( + LoggingMessageNotification.builder(LoggingLevel.INFO, "Tool processing data").build()); + exchange.loggingNotification( + LoggingMessageNotification.builder(LoggingLevel.INFO, "Tool execution completed") + .build()); return CallToolResult.builder() .content(List.of(new TextContent("Tool execution completed with logging"))) .isError(false) From 86b9151bc6d30b08ad3c991ccdc8eebf238c1bb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:31:08 +0200 Subject: [PATCH 5/9] Accept missing required fields on deserialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some clients or servers fail to provide required JSON fields so instead of failing we replace them by default values and log with WARN level and allow the application to make progress. Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../modelcontextprotocol/spec/McpSchema.java | 139 ++++++++++++++++++ .../spec/McpSchemaTests.java | 61 ++++++++ 2 files changed, 200 insertions(+) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index abd6e0ff9..4bd8f6889 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1599,6 +1599,10 @@ public CallToolRequest build() { * @param structuredContent An optional JSON object that represents the structured * result of the tool call. * @param meta See specification for notes on _meta usage + *

+ * Note: {@code content} is required by the MCP specification. Deserialization accepts + * a missing value and substitutes an empty list to avoid breaking existing + * integrations that may omit the field. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -1612,6 +1616,17 @@ public record CallToolResult( // @formatter:off Assert.notNull(content, "content must not be null"); } + @JsonCreator + static CallToolResult fromJson(@JsonProperty("content") List content, + @JsonProperty("isError") Boolean isError, @JsonProperty("structuredContent") Object structuredContent, + @JsonProperty("_meta") Map meta) { + if (content == null) { + logger.warn("CallToolResult: missing required fields during deserialization: content -> []"); + content = List.of(); + } + return new CallToolResult(content, isError, structuredContent, meta); + } + /** * Creates a builder for {@link CallToolResult}. * @return a new builder instance @@ -1839,6 +1854,10 @@ public static ModelHint of(String name) { * * @param role The sender or recipient of messages and data in a conversation * @param content The content of the message + *

+ * Note: {@code role} and {@code content} are required by the MCP specification. + * Deserialization accepts missing values and substitutes defaults to avoid breaking + * existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -1850,6 +1869,24 @@ public record SamplingMessage( // @formatter:off Assert.notNull(role, "role must not be null"); Assert.notNull(content, "content must not be null"); } + + @JsonCreator + static SamplingMessage fromJson(@JsonProperty("role") Role role, @JsonProperty("content") Content content) { + if (role == null || content == null) { + List missing = new ArrayList<>(); + if (role == null) { + missing.add("role -> 'user'"); + role = Role.USER; + } + if (content == null) { + missing.add("content -> ''"); + content = new TextContent(""); + } + logger.warn("SamplingMessage: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new SamplingMessage(role, content); + } } /** @@ -1873,6 +1910,10 @@ public record SamplingMessage( // @formatter:off * @param metadata Optional metadata to pass through to the LLM provider. The format * of this metadata is provider-specific * @param meta See specification for notes on _meta usage + *

+ * Note: {@code messages} and {@code maxTokens} are required by the MCP specification. + * Deserialization accepts missing values and substitutes defaults to avoid breaking + * existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -1892,6 +1933,32 @@ public record CreateMessageRequest( // @formatter:off Assert.notNull(maxTokens, "maxTokens must not be null"); } + @JsonCreator + static CreateMessageRequest fromJson(@JsonProperty("messages") List messages, + @JsonProperty("modelPreferences") ModelPreferences modelPreferences, + @JsonProperty("systemPrompt") String systemPrompt, + @JsonProperty("includeContext") ContextInclusionStrategy includeContext, + @JsonProperty("temperature") Double temperature, @JsonProperty("maxTokens") Integer maxTokens, + @JsonProperty("stopSequences") List stopSequences, + @JsonProperty("metadata") Map metadata, + @JsonProperty("_meta") Map meta) { + if (messages == null || maxTokens == null) { + List missing = new ArrayList<>(); + if (messages == null) { + missing.add("messages -> []"); + messages = List.of(); + } + if (maxTokens == null) { + missing.add("maxTokens -> 0"); + maxTokens = 0; + } + logger.warn("CreateMessageRequest: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new CreateMessageRequest(messages, modelPreferences, systemPrompt, includeContext, temperature, + maxTokens, stopSequences, metadata, meta); + } + // backwards compatibility constructor public CreateMessageRequest(List messages, ModelPreferences modelPreferences, String systemPrompt, ContextInclusionStrategy includeContext, Double temperature, Integer maxTokens, @@ -2136,6 +2203,10 @@ public CreateMessageResult build() { * @param requestedSchema A restricted subset of JSON Schema. Only top-level * properties are allowed, without nesting * @param meta See specification for notes on _meta usage + *

+ * Note: {@code message} and {@code requestedSchema} are required by the MCP + * specification. Deserialization accepts missing values and substitutes defaults to + * avoid breaking existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -2149,6 +2220,26 @@ public record ElicitRequest( // @formatter:off Assert.notNull(requestedSchema, "requestedSchema must not be null"); } + @JsonCreator + static ElicitRequest fromJson(@JsonProperty("message") String message, + @JsonProperty("requestedSchema") Map requestedSchema, + @JsonProperty("_meta") Map meta) { + if (message == null || requestedSchema == null) { + List missing = new ArrayList<>(); + if (message == null) { + missing.add("message -> ''"); + message = ""; + } + if (requestedSchema == null) { + missing.add("requestedSchema -> {}"); + requestedSchema = Map.of(); + } + logger.warn("ElicitRequest: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ElicitRequest(message, requestedSchema, meta); + } + // backwards compatibility constructor public ElicitRequest(String message, Map requestedSchema) { this(message, requestedSchema, null); @@ -2340,6 +2431,10 @@ public record PaginatedResult(@JsonProperty("nextCursor") String nextCursor) { * @param total An optional total amount of work to be done, if known. * @param message An optional message providing additional context about the progress. * @param meta See specification for notes on _meta usage + *

+ * Note: {@code progressToken} and {@code progress} are required by the MCP + * specification. Deserialization accepts missing values and substitutes defaults to + * avoid breaking existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -2355,6 +2450,26 @@ public record ProgressNotification( // @formatter:off Assert.notNull(progress, "progress must not be null"); } + @JsonCreator + static ProgressNotification fromJson(@JsonProperty("progressToken") Object progressToken, + @JsonProperty("progress") Double progress, @JsonProperty("total") Double total, + @JsonProperty("message") String message, @JsonProperty("_meta") Map meta) { + if (progressToken == null || progress == null) { + List missing = new ArrayList<>(); + if (progressToken == null) { + missing.add("progressToken -> ''"); + progressToken = ""; + } + if (progress == null) { + missing.add("progress -> 0.0"); + progress = 0.0; + } + logger.warn("ProgressNotification: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ProgressNotification(progressToken, progress, total, message, meta); + } + public ProgressNotification(Object progressToken, double progress, Double total, String message) { this(progressToken, progress, total, message, null); } @@ -2432,6 +2547,10 @@ public ResourcesUpdatedNotification(String uri) { * @param logger The logger that generated the message. * @param data JSON-serializable logging data. * @param meta See specification for notes on _meta usage + *

+ * Note: {@code level} and {@code data} are required by the MCP specification. + * Deserialization accepts missing values and substitutes defaults to avoid breaking + * existing integrations that may omit these fields. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) @@ -2446,6 +2565,26 @@ public record LoggingMessageNotification( // @formatter:off Assert.notNull(data, "data must not be null"); } + @JsonCreator + static LoggingMessageNotification fromJson(@JsonProperty("level") LoggingLevel level, + @JsonProperty("logger") String loggerName, @JsonProperty("data") String data, + @JsonProperty("_meta") Map meta) { + if (level == null || data == null) { + List missing = new ArrayList<>(); + if (level == null) { + missing.add("level -> INFO"); + level = LoggingLevel.INFO; + } + if (data == null) { + missing.add("data -> ''"); + data = ""; + } + McpSchema.logger.warn("LoggingMessageNotification: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new LoggingMessageNotification(level, loggerName, data, meta); + } + // backwards compatibility constructor public LoggingMessageNotification(LoggingLevel level, String logger, String data) { this(level, logger, data, null); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index 9cc9f1e11..d9e10d082 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -1346,6 +1346,16 @@ void testCallToolResultBuilderWithErrorResult() throws Exception { {"content":[{"type":"text","text":"Error: Operation failed"}],"isError":true}""")); } + @Test + void testCallToolResultDeserializationWithMissingContent() throws Exception { + McpSchema.CallToolResult result = JSON_MAPPER.readValue(""" + {"isError":false}""", McpSchema.CallToolResult.class); + + assertThat(result).isNotNull(); + assertThat(result.content()).isEmpty(); + assertThat(result.isError()).isFalse(); + } + // Sampling Tests @Test @@ -1382,6 +1392,26 @@ void testCreateMessageRequest() throws Exception { {"messages":[{"role":"user","content":{"type":"text","text":"User message"}}],"modelPreferences":{"hints":[{"name":"gpt-4"}],"costPriority":0.3,"speedPriority":0.7,"intelligencePriority":0.9},"systemPrompt":"You are a helpful assistant","includeContext":"thisServer","temperature":0.7,"maxTokens":1000,"stopSequences":["STOP","END"],"metadata":{"session":"test-session"}}""")); } + @Test + void testSamplingMessageDeserializationWithMissingFields() throws Exception { + McpSchema.SamplingMessage message = JSON_MAPPER.readValue("{}", McpSchema.SamplingMessage.class); + + assertThat(message).isNotNull(); + assertThat(message.role()).isEqualTo(McpSchema.Role.USER); + assertThat(message.content()).isInstanceOf(McpSchema.TextContent.class); + } + + @Test + void testCreateMessageRequestDeserializationWithMissingRequiredFields() throws Exception { + McpSchema.CreateMessageRequest request = JSON_MAPPER.readValue(""" + {"systemPrompt":"hello"}""", McpSchema.CreateMessageRequest.class); + + assertThat(request).isNotNull(); + assertThat(request.messages()).isEmpty(); + assertThat(request.maxTokens()).isZero(); + assertThat(request.systemPrompt()).isEqualTo("hello"); + } + @Test void testCreateMessageResult() throws Exception { McpSchema.TextContent content = new McpSchema.TextContent("Assistant response"); @@ -1455,6 +1485,15 @@ void testCreateElicitationResult() throws Exception { {"action":"accept","content":{"foo":"bar"}}""")); } + @Test + void testElicitRequestDeserializationWithMissingRequiredFields() throws Exception { + McpSchema.ElicitRequest request = JSON_MAPPER.readValue("{}", McpSchema.ElicitRequest.class); + + assertThat(request).isNotNull(); + assertThat(request.message()).isEmpty(); + assertThat(request.requestedSchema()).isEmpty(); + } + @Test void testElicitRequestWithMeta() throws Exception { Map requestedSchema = Map.of("type", "object", "required", List.of("name"), "properties", @@ -1752,6 +1791,17 @@ void testProgressNotificationDeserialization() throws Exception { assertThat(notification.meta()).containsEntry("key", "value"); } + @Test + void testProgressNotificationDeserializationWithMissingRequiredFields() throws Exception { + McpSchema.ProgressNotification notification = JSON_MAPPER.readValue(""" + {"total":1.0}""", McpSchema.ProgressNotification.class); + + assertThat(notification).isNotNull(); + assertThat(notification.progressToken()).isEqualTo(""); + assertThat(notification.progress()).isZero(); + assertThat(notification.total()).isEqualTo(1.0); + } + @Test void testProgressNotificationWithoutMessage() throws Exception { McpSchema.ProgressNotification notification = new McpSchema.ProgressNotification("progress-token-789", 0.25, @@ -1765,4 +1815,15 @@ void testProgressNotificationWithoutMessage() throws Exception { {"progressToken":"progress-token-789","progress":0.25}""")); } + @Test + void testLoggingMessageNotificationDeserializationWithMissingRequiredFields() throws Exception { + McpSchema.LoggingMessageNotification notification = JSON_MAPPER.readValue(""" + {"logger":"my-logger"}""", McpSchema.LoggingMessageNotification.class); + + assertThat(notification).isNotNull(); + assertThat(notification.level()).isEqualTo(McpSchema.LoggingLevel.INFO); + assertThat(notification.logger()).isEqualTo("my-logger"); + assertThat(notification.data()).isEmpty(); + } + } From cd4d1a5d8574fcb5d36a72c48a61bf8db2d7d5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:41:22 +0200 Subject: [PATCH 6/9] Update migration notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- MIGRATION-2.0.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md index 261670768..d8c7bf28c 100644 --- a/MIGRATION-2.0.md +++ b/MIGRATION-2.0.md @@ -93,6 +93,10 @@ The following records assert that their required fields are non-null at construc **Action:** Audit any code that constructs these records with potentially-null values and provide valid, non-null arguments. +**Wire deserialization is lenient** + +Deserialization substitutes safe defaults for absent required fields instead of failing. A `WARN` is logged for every field that was defaulted. `JSONRPCResponse.JSONRPCError` is excluded — malformed JSON-RPC error envelopes still fail immediately. + #### Builder API changes 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. @@ -108,7 +112,7 @@ Two records that previously had no builder now have one with the same required-f - `ProgressNotification.builder(progressToken, progress)` — optional: `.total(Double)`, `.message(String)`, `.meta(Map)` - `JSONRPCResponse.JSONRPCError.builder(code, message)` — optional: `.data(Object)` -**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. +**Note:** A *missing* `level` field on the wire is handled — it defaults to `INFO` (see the wire-defaults table above). However, an *unrecognized* level string still deserializes to `null` (see the `LoggingLevel` section above), which will then fail the canonical constructor. Ensure clients and servers send only recognized level strings. ### Optional JSON Schema validation on `tools/call` (server) From 4c8596306de503d374504e0fb80d6effada7d5f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:20:38 +0200 Subject: [PATCH 7/9] feat!: consistent JSON forward/backward compatibility - 2.0 foundation (#972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- CONTRIBUTING.md | 77 +++++++++ MIGRATION-2.0.md | 84 +++++++++ .../client/transport/ServerParameters.java | 2 +- .../server/McpAsyncServer.java | 49 +----- .../server/McpStatelessAsyncServer.java | 41 +---- .../modelcontextprotocol/spec/McpSchema.java | 118 +++++++++---- .../spec/CompleteReferenceJsonTests.java | 94 ++++++++++ .../spec/ContentJsonTests.java | 78 +++++++++ .../spec/JsonRpcDispatchTests.java | 100 +++++++++++ .../spec/SchemaEvolutionTests.java | 162 ++++++++++++++++++ 10 files changed, 688 insertions(+), 117 deletions(-) create mode 100644 MIGRATION-2.0.md create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/JsonRpcDispatchTests.java create mode 100644 mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 517f32555..6494f321c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 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 diff --git a/MIGRATION-2.0.md b/MIGRATION-2.0.md new file mode 100644 index 000000000..273421189 --- /dev/null +++ b/MIGRATION-2.0.md @@ -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`, not `JsonSchema` + +The `Tool` record now models `inputSchema` (and `outputSchema`) as arbitrary JSON Schema objects as `Map`, 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` (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. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java index 25a02279f..f7c89aa22 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/ServerParameters.java @@ -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; diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 30a3146a7..e5f57bad8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server; @@ -971,7 +971,8 @@ private McpRequestHandler setLoggerRequestHandler() { private McpRequestHandler completionCompleteRequestHandler() { return (exchange, params) -> { - McpSchema.CompleteRequest request = parseCompletionParams(params); + McpSchema.CompleteRequest request = jsonMapper.convertValue(params, new TypeRef<>() { + }); if (request.ref() == null) { return Mono.error( @@ -1072,50 +1073,6 @@ private McpRequestHandler completionCompleteRequestHan }; } - /** - * Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest} - * object. - *

- * 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 params = (Map) object; - Map refMap = (Map) params.get("ref"); - Map argMap = (Map) params.get("argument"); - Map contextMap = (Map) params.get("context"); - Map meta = (Map) 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 arguments = (Map) 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. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index e85451af9..18fc85786 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.server; @@ -715,7 +715,8 @@ private McpStatelessRequestHandler promptsGetRequestH private McpStatelessRequestHandler completionCompleteRequestHandler() { return (ctx, params) -> { - McpSchema.CompleteRequest request = parseCompletionParams(params); + McpSchema.CompleteRequest request = jsonMapper.convertValue(params, new TypeRef<>() { + }); if (request.ref() == null) { return Mono.error( @@ -815,42 +816,6 @@ private McpStatelessRequestHandler completionCompleteR }; } - /** - * Parses the raw JSON-RPC request parameters into a {@link McpSchema.CompleteRequest} - * object. - *

- * 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 params = (Map) object; - Map refMap = (Map) params.get("ref"); - Map argMap = (Map) 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. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 2e7f73b72..a3ed2dbde 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -1,17 +1,17 @@ /* - * Copyright 2024-2024 the original author or authors. + * Copyright 2024-2026 the original author or authors. */ package io.modelcontextprotocol.spec; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -33,6 +33,7 @@ * @author Luca Chang * @author Surbhi Bansal * @author Anurag Pant + * @author Dariusz Jędrzejczyk */ public final class McpSchema { @@ -160,9 +161,7 @@ public interface Meta { } - public sealed interface Request extends Meta - permits InitializeRequest, CallToolRequest, CreateMessageRequest, ElicitRequest, CompleteRequest, - GetPromptRequest, ReadResourceRequest, SubscribeRequest, UnsubscribeRequest, PaginatedRequest { + public interface Request extends Meta { default Object progressToken() { if (meta() != null && meta().containsKey("progressToken")) { @@ -173,14 +172,11 @@ default Object progressToken() { } - public sealed interface Result extends Meta permits InitializeResult, ListResourcesResult, - ListResourceTemplatesResult, ReadResourceResult, ListPromptsResult, GetPromptResult, ListToolsResult, - CallToolResult, CreateMessageResult, ElicitResult, CompleteResult, ListRootsResult { + public interface Result extends Meta { } - public sealed interface Notification extends Meta - permits ProgressNotification, LoggingMessageNotification, ResourcesUpdatedNotification { + public interface Notification extends Meta { } @@ -199,7 +195,6 @@ public sealed interface Notification extends Meta */ public static JSONRPCMessage deserializeJsonRpcMessage(McpJsonMapper jsonMapper, String jsonText) throws IOException { - logger.debug("Received JSON message: {}", jsonText); var map = jsonMapper.readValue(jsonText, MAP_TYPE_REF); @@ -221,7 +216,7 @@ else if (map.containsKey("result") || map.containsKey("error")) { // --------------------------- // JSON-RPC Message Types // --------------------------- - public sealed interface JSONRPCMessage permits JSONRPCRequest, JSONRPCNotification, JSONRPCResponse { + public interface JSONRPCMessage { String jsonrpc(); @@ -237,7 +232,6 @@ public sealed interface JSONRPCMessage permits JSONRPCRequest, JSONRPCNotificati */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - // @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) public record JSONRPCRequest( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("method") String method, @@ -265,8 +259,6 @@ public record JSONRPCRequest( // @formatter:off */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - // TODO: batching support - // @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) public record JSONRPCNotification( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("method") String method, @@ -283,8 +275,6 @@ public record JSONRPCNotification( // @formatter:off */ @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) - // TODO: batching support - // @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) public record JSONRPCResponse( // @formatter:off @JsonProperty("jsonrpc") String jsonrpc, @JsonProperty("id") Object id, @@ -404,6 +394,7 @@ public record RootCapabilities(@JsonProperty("listChanged") Boolean listChanged) * from MCP servers in their prompts. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Sampling() { } @@ -431,12 +422,14 @@ public record Sampling() { * @param url support for out-of-band URL-based elicitation */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Elicitation(@JsonProperty("form") Form form, @JsonProperty("url") Url url) { /** * Marker record indicating support for form-based elicitation mode. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Form() { } @@ -444,6 +437,7 @@ public record Form() { * Marker record indicating support for URL-based elicitation mode. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record Url() { } @@ -542,6 +536,7 @@ public record ServerCapabilities( // @formatter:off * Present if the server supports argument autocompletion suggestions. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record CompletionCapabilities() { } @@ -549,6 +544,7 @@ public record CompletionCapabilities() { * Present if the server supports sending log messages to the client. */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record LoggingCapabilities() { } @@ -559,6 +555,7 @@ public record LoggingCapabilities() { * the prompt list */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record PromptCapabilities(@JsonProperty("listChanged") Boolean listChanged) { } @@ -570,6 +567,7 @@ public record PromptCapabilities(@JsonProperty("listChanged") Boolean listChange * the resource list */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ResourceCapabilities(@JsonProperty("subscribe") Boolean subscribe, @JsonProperty("listChanged") Boolean listChanged) { } @@ -581,6 +579,7 @@ public record ResourceCapabilities(@JsonProperty("subscribe") Boolean subscribe, * the tool list */ @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record ToolCapabilities(@JsonProperty("listChanged") Boolean listChanged) { } @@ -1089,7 +1088,7 @@ public UnsubscribeRequest(String uri) { @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) @JsonSubTypes({ @JsonSubTypes.Type(value = TextResourceContents.class), @JsonSubTypes.Type(value = BlobResourceContents.class) }) - public sealed interface ResourceContents extends Meta permits TextResourceContents, BlobResourceContents { + public interface ResourceContents extends Meta { /** * The URI of this resource. @@ -1172,11 +1171,11 @@ public record Prompt( // @formatter:off @JsonProperty("_meta") Map meta) implements Identifier { // @formatter:on public Prompt(String name, String description, List arguments) { - this(name, null, description, arguments != null ? arguments : new ArrayList<>()); + this(name, null, description, arguments, null); } public Prompt(String name, String title, String description, List arguments) { - this(name, title, description, arguments != null ? arguments : new ArrayList<>(), null); + this(name, title, description, arguments, null); } } @@ -1848,7 +1847,7 @@ public enum ContextInclusionStrategy { // @formatter:off @JsonProperty("none") NONE, @JsonProperty("thisServer") THIS_SERVER, - @JsonProperty("allServers")ALL_SERVERS + @JsonProperty("allServers") ALL_SERVERS } // @formatter:on public static Builder builder() { @@ -1960,28 +1959,36 @@ public record CreateMessageResult( // @formatter:off public enum StopReason { // @formatter:off + @JsonProperty("endTurn") END_TURN("endTurn"), @JsonProperty("stopSequence") STOP_SEQUENCE("stopSequence"), @JsonProperty("maxTokens") MAX_TOKENS("maxTokens"), - @JsonProperty("unknown") UNKNOWN("unknown"); - // @formatter:on + @JsonProperty("unknown") UNKNOWN("unknown"); // @formatter:on private final String value; + private static final Map BY_VALUE; + + static { + Map m = new HashMap<>(); + for (StopReason r : values()) { + m.put(r.value, r); + } + BY_VALUE = Map.copyOf(m); + } + StopReason(String value) { this.value = value; } @JsonCreator - private static StopReason of(String value) { - return Arrays.stream(StopReason.values()) - .filter(stopReason -> stopReason.value.equals(value)) - .findFirst() - .orElse(StopReason.UNKNOWN); + public static StopReason of(String value) { + return BY_VALUE.getOrDefault(value, UNKNOWN); } } + // backwards compatibility constructor public CreateMessageResult(Role role, Content content, String model, StopReason stopReason) { this(role, content, model, stopReason, null); } @@ -2123,9 +2130,11 @@ public record ElicitResult( // @formatter:off public enum Action { // @formatter:off + @JsonProperty("accept") ACCEPT, @JsonProperty("decline") DECLINE, @JsonProperty("cancel") CANCEL + } // @formatter:on // backwards compatibility constructor @@ -2319,9 +2328,15 @@ public LoggingMessageNotification build() { } } + /** + * Severity levels for MCP log messages, ordered from least to most severe. The + * numeric {@link #level()} can be used to compare severities. Deserialization is + * case-insensitive and returns {@code null} for unrecognized values. + */ public enum LoggingLevel { // @formatter:off + @JsonProperty("debug") DEBUG(0), @JsonProperty("info") INFO(1), @JsonProperty("notice") NOTICE(2), @@ -2334,6 +2349,16 @@ public enum LoggingLevel { private final int level; + private static final Map BY_NAME; + + static { + Map m = new HashMap<>(); + for (LoggingLevel l : values()) { + m.put(l.name().toLowerCase(), l); + } + BY_NAME = Map.copyOf(m); + } + LoggingLevel(int level) { this.level = level; } @@ -2342,6 +2367,11 @@ public int level() { return level; } + @JsonCreator + public static LoggingLevel fromValue(String value) { + return value == null ? null : BY_NAME.get(value.toLowerCase()); + } + } /** @@ -2359,7 +2389,17 @@ public record SetLevelRequest(@JsonProperty("level") LoggingLevel level) { // --------------------------- // Autocomplete // --------------------------- - public sealed interface CompleteReference permits PromptReference, ResourceReference { + + /** + * A reference to a prompt or resource that can be used as input for completion + * requests. Implementations are identified by a {@code "type"} discriminator field + * whose value maps to a concrete subtype via {@code @JsonSubTypes}. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", + visible = true) + @JsonSubTypes({ @JsonSubTypes.Type(value = PromptReference.class, name = PromptReference.TYPE), + @JsonSubTypes.Type(value = ResourceReference.class, name = ResourceReference.TYPE) }) + public interface CompleteReference { String type(); @@ -2471,6 +2511,8 @@ public CompleteRequest(McpSchema.CompleteReference ref, CompleteArgument argumen * @param name The name of the argument * @param value The value of the argument to use for completion matching */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record CompleteArgument(@JsonProperty("name") String name, @JsonProperty("value") String value) { } @@ -2479,6 +2521,8 @@ public record CompleteArgument(@JsonProperty("name") String name, @JsonProperty( * * @param arguments Previously-resolved variables in a URI template or prompt */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record CompleteContext(@JsonProperty("arguments") Map arguments) { } } @@ -2509,26 +2553,36 @@ public CompleteResult(CompleteCompletion completion) { * @param hasMore Indicates whether there are additional completion options beyond * those provided in the current response, even if the exact total is unknown */ - @JsonInclude(JsonInclude.Include.ALWAYS) + @JsonInclude(JsonInclude.Include.NON_ABSENT) + @JsonIgnoreProperties(ignoreUnknown = true) public record CompleteCompletion( // @formatter:off @JsonProperty("values") List values, @JsonProperty("total") Integer total, @JsonProperty("hasMore") Boolean hasMore) { // @formatter:on + + public CompleteCompletion { + Assert.notNull(values, "values must not be null"); + } } } // --------------------------- // Content Types // --------------------------- + + /** + * A polymorphic content value that can appear in messages and tool results. The + * concrete type is determined by the {@code "type"} JSON property. + */ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = "text"), @JsonSubTypes.Type(value = ImageContent.class, name = "image"), @JsonSubTypes.Type(value = AudioContent.class, name = "audio"), @JsonSubTypes.Type(value = EmbeddedResource.class, name = "resource"), @JsonSubTypes.Type(value = ResourceLink.class, name = "resource_link") }) - public sealed interface Content extends Meta - permits TextContent, ImageContent, AudioContent, EmbeddedResource, ResourceLink { + public interface Content extends Meta { + @JsonIgnore default String type() { if (this instanceof TextContent) { return "text"; diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java new file mode 100644 index 000000000..1b23c5059 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link McpSchema.CompleteReference} polymorphic dispatch works via direct + * {@code readValue} on {@link McpSchema.CompleteRequest} — no hand-rolled map-walking + * required. + */ +class CompleteReferenceJsonTests { + + private final McpJsonMapper mapper = JSON_MAPPER; + + @Test + void promptReferenceSerializesCorrectly() throws IOException { + McpSchema.PromptReference ref = new McpSchema.PromptReference("my-prompt"); + + String json = mapper.writeValueAsString(ref); + assertThatJson(json).node("type").isEqualTo("ref/prompt"); + assertThatJson(json).node("name").isEqualTo("my-prompt"); + } + + @Test + void resourceReferenceSerializesCorrectly() throws IOException { + McpSchema.ResourceReference ref = new McpSchema.ResourceReference("file:///foo.txt"); + + String json = mapper.writeValueAsString(ref); + assertThatJson(json).node("type").isEqualTo("ref/resource"); + assertThatJson(json).node("uri").isEqualTo("file:///foo.txt"); + } + + @Test + void completeRequestReadValueDispatchesPromptRef() throws IOException { + String json = """ + {"ref":{"type":"ref/prompt","name":"my-prompt"},"argument":{"name":"lang","value":"java"}} + """; + + McpSchema.CompleteRequest req = mapper.readValue(json, McpSchema.CompleteRequest.class); + + assertThat(req.ref()).isInstanceOf(McpSchema.PromptReference.class); + assertThat(req.ref().identifier()).isEqualTo("my-prompt"); + assertThat(req.argument().name()).isEqualTo("lang"); + assertThat(req.argument().value()).isEqualTo("java"); + } + + @Test + void completeRequestReadValueDispatchesResourceRef() throws IOException { + String json = """ + {"ref":{"type":"ref/resource","uri":"file:///src/Foo.java"},"argument":{"name":"q","value":"main"}} + """; + + McpSchema.CompleteRequest req = mapper.readValue(json, McpSchema.CompleteRequest.class); + + assertThat(req.ref()).isInstanceOf(McpSchema.ResourceReference.class); + assertThat(req.ref().identifier()).isEqualTo("file:///src/Foo.java"); + } + + @Test + void completeRequestConvertValueFromMapDispatchesPromptRef() throws IOException { + String json = """ + {"ref":{"type":"ref/prompt","name":"my-prompt"},"argument":{"name":"lang","value":"java"}} + """; + + // This is the real in-process path: params arrives as a Map from JSON-RPC + Object paramsMap = mapper.readValue(json, Object.class); + McpSchema.CompleteRequest req = mapper.convertValue(paramsMap, new TypeRef() { + }); + + assertThat(req.ref()).isInstanceOf(McpSchema.PromptReference.class); + assertThat(req.ref().identifier()).isEqualTo("my-prompt"); + } + + @Test + void typeDiscriminatorAppearsExactlyOnce() throws IOException { + McpSchema.PromptReference ref = new McpSchema.PromptReference("p"); + String json = mapper.writeValueAsString(ref); + + long typeCount = java.util.Arrays.stream(json.split("\"type\"")).count() - 1; + assertThat(typeCount).as("type property should appear exactly once").isEqualTo(1); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java new file mode 100644 index 000000000..35f06620b --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/ContentJsonTests.java @@ -0,0 +1,78 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import io.modelcontextprotocol.json.McpJsonMapper; +import org.junit.jupiter.api.Test; + +/** + * Verifies that every {@link McpSchema.Content} subtype serializes with exactly one + * {@code type} property (regression guard for the {@code @JsonIgnore} on the default + * {@code type()} method). + */ +class ContentJsonTests { + + private final McpJsonMapper mapper = JSON_MAPPER; + + @Test + void textContentHasExactlyOneTypeProperty() throws IOException { + McpSchema.TextContent content = new McpSchema.TextContent("hello"); + String json = mapper.writeValueAsString(content); + + assertExactlyOneTypeProperty(json); + assertThatJson(json).node("type").isEqualTo("text"); + assertThatJson(json).node("text").isEqualTo("hello"); + } + + @Test + void imageContentHasExactlyOneTypeProperty() throws IOException { + McpSchema.ImageContent content = new McpSchema.ImageContent(null, "base64data", "image/png"); + String json = mapper.writeValueAsString(content); + + assertExactlyOneTypeProperty(json); + assertThatJson(json).node("type").isEqualTo("image"); + } + + @Test + void audioContentHasExactlyOneTypeProperty() throws IOException { + McpSchema.AudioContent content = new McpSchema.AudioContent(null, "base64data", "audio/mp3"); + String json = mapper.writeValueAsString(content); + + assertExactlyOneTypeProperty(json); + assertThatJson(json).node("type").isEqualTo("audio"); + } + + @Test + void textContentRoundTrip() throws IOException { + McpSchema.TextContent original = new McpSchema.TextContent("round-trip"); + String json = mapper.writeValueAsString(original); + + McpSchema.Content decoded = mapper.readValue(json, McpSchema.Content.class); + assertThat(decoded).isInstanceOf(McpSchema.TextContent.class); + assertThat(((McpSchema.TextContent) decoded).text()).isEqualTo("round-trip"); + } + + @Test + void textContentToleratesUnknownFields() throws IOException { + String json = """ + {"type":"text","text":"hi","unknownField":"ignored","anotherField":42} + """; + McpSchema.Content decoded = mapper.readValue(json, McpSchema.Content.class); + assertThat(decoded).isInstanceOf(McpSchema.TextContent.class); + assertThat(((McpSchema.TextContent) decoded).text()).isEqualTo("hi"); + } + + private static void assertExactlyOneTypeProperty(String json) { + long count = java.util.Arrays.stream(json.split("\"type\"")).count() - 1; + assertThat(count).as("'type' property must appear exactly once in: %s", json).isEqualTo(1); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/JsonRpcDispatchTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/JsonRpcDispatchTests.java new file mode 100644 index 000000000..6e5a6efb2 --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/JsonRpcDispatchTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.Map; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.TypeRef; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link McpSchema#deserializeJsonRpcMessage} dispatches to the correct + * concrete subtype for all four JSON-RPC message shapes, and that {@code params} / + * {@code result} survive the round-trip. + */ +class JsonRpcDispatchTests { + + private final McpJsonMapper mapper = JSON_MAPPER; + + @Test + void dispatchesRequest() throws IOException { + String json = """ + {"jsonrpc":"2.0","id":"req-1","method":"tools/call","params":{"name":"echo","arguments":{"x":1}}} + """; + + McpSchema.JSONRPCMessage msg = McpSchema.deserializeJsonRpcMessage(mapper, json); + + assertThat(msg).isInstanceOf(McpSchema.JSONRPCRequest.class); + McpSchema.JSONRPCRequest req = (McpSchema.JSONRPCRequest) msg; + assertThat(req.jsonrpc()).isEqualTo("2.0"); + assertThat(req.method()).isEqualTo("tools/call"); + assertThat(req.id()).isEqualTo("req-1"); + assertThat(req.params()).isNotNull(); + } + + @Test + void dispatchesNotification() throws IOException { + String json = """ + {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} + """; + + McpSchema.JSONRPCMessage msg = McpSchema.deserializeJsonRpcMessage(mapper, json); + + assertThat(msg).isInstanceOf(McpSchema.JSONRPCNotification.class); + McpSchema.JSONRPCNotification notif = (McpSchema.JSONRPCNotification) msg; + assertThat(notif.method()).isEqualTo("notifications/initialized"); + } + + @Test + void dispatchesSuccessResponse() throws IOException { + String json = """ + {"jsonrpc":"2.0","id":"req-1","result":{"content":[{"type":"text","text":"hi"}]}} + """; + + McpSchema.JSONRPCMessage msg = McpSchema.deserializeJsonRpcMessage(mapper, json); + + assertThat(msg).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse resp = (McpSchema.JSONRPCResponse) msg; + assertThat(resp.error()).isNull(); + assertThat(resp.result()).isNotNull(); + } + + @Test + void dispatchesErrorResponse() throws IOException { + String json = """ + {"jsonrpc":"2.0","id":"req-1","error":{"code":-32601,"message":"Method not found"}} + """; + + McpSchema.JSONRPCMessage msg = McpSchema.deserializeJsonRpcMessage(mapper, json); + + assertThat(msg).isInstanceOf(McpSchema.JSONRPCResponse.class); + McpSchema.JSONRPCResponse resp = (McpSchema.JSONRPCResponse) msg; + assertThat(resp.error()).isNotNull(); + assertThat(resp.error().code()).isEqualTo(-32601); + assertThat(resp.result()).isNull(); + } + + @Test + void paramsMapSurvivesConvertValue() throws IOException { + String json = """ + {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"x":42}}} + """; + + McpSchema.JSONRPCRequest req = (McpSchema.JSONRPCRequest) McpSchema.deserializeJsonRpcMessage(mapper, json); + + McpSchema.CallToolRequest call = mapper.convertValue(req.params(), new TypeRef() { + }); + assertThat(call.name()).isEqualTo("echo"); + @SuppressWarnings("unchecked") + Map args = (Map) call.arguments(); + assertThat(((Number) args.get("x")).intValue()).isEqualTo(42); + } + +} diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java new file mode 100644 index 000000000..f80fbcb6e --- /dev/null +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/SchemaEvolutionTests.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026 - 2026 the original author or authors. + */ + +package io.modelcontextprotocol.spec; + +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.util.List; + +import io.modelcontextprotocol.json.McpJsonMapper; +import org.junit.jupiter.api.Test; + +/** + * Forward/backward compatibility tests for wire-serialized records: + *

    + *
  • Unknown fields are ignored (forward compat: old client, new server).
  • + *
  • Optional fields absent from wire deserialize to {@code null} (backward + * compat).
  • + *
  • Null optional fields are omitted from serialized output ({@code NON_ABSENT}).
  • + *
+ */ +class SchemaEvolutionTests { + + private final McpJsonMapper mapper = JSON_MAPPER; + + // ----------------------------------------------------------------------- + // TextContent + // ----------------------------------------------------------------------- + + @Test + void textContentUnknownFieldsIgnored() throws IOException { + String json = """ + {"type":"text","text":"hi","newFieldFromFutureVersion":"ignored","nested":{"a":1}} + """; + McpSchema.TextContent content = mapper.readValue(json, McpSchema.TextContent.class); + assertThat(content.text()).isEqualTo("hi"); + } + + @Test + void textContentNullAnnotationsOmitted() throws IOException { + McpSchema.TextContent content = new McpSchema.TextContent(null, "hello"); + String json = mapper.writeValueAsString(content); + assertThat(json).doesNotContain("annotations"); + } + + // ----------------------------------------------------------------------- + // Prompt — null arguments must NOT coerce to empty list on the wire + // ----------------------------------------------------------------------- + + @Test + void promptWithNullArgumentsDeserializesAsNull() throws IOException { + String json = """ + {"name":"p","description":"desc"} + """; + McpSchema.Prompt prompt = mapper.readValue(json, McpSchema.Prompt.class); + assertThat(prompt.arguments()).isNull(); + } + + @Test + void promptWithNullArgumentsOmitsFieldOnWire() throws IOException { + McpSchema.Prompt prompt = new McpSchema.Prompt("p", "desc", (List) null); + String json = mapper.writeValueAsString(prompt); + assertThat(json).doesNotContain("arguments"); + } + + @Test + void promptUnknownFieldsIgnored() throws IOException { + String json = """ + {"name":"p","description":"desc","futureField":true} + """; + McpSchema.Prompt prompt = mapper.readValue(json, McpSchema.Prompt.class); + assertThat(prompt.name()).isEqualTo("p"); + } + + // ----------------------------------------------------------------------- + // InitializeRequest + // ----------------------------------------------------------------------- + + @Test + void initializeRequestUnknownFieldsIgnored() throws IOException { + String json = """ + {"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}, + "unknownFuture":"value"} + """; + McpSchema.InitializeRequest req = mapper.readValue(json, McpSchema.InitializeRequest.class); + assertThat(req.protocolVersion()).isEqualTo("2025-06-18"); + } + + // ----------------------------------------------------------------------- + // CompleteCompletion — NON_ABSENT (was ALWAYS) + // ----------------------------------------------------------------------- + + @Test + void completeCompletionOmitsNullOptionals() throws IOException { + McpSchema.CompleteResult.CompleteCompletion c = new McpSchema.CompleteResult.CompleteCompletion(List.of("x"), + null, null); + String json = mapper.writeValueAsString(c); + assertThat(json).doesNotContain("total"); + assertThat(json).doesNotContain("hasMore"); + } + + @Test + void completeCompletionUnknownFieldsIgnored() throws IOException { + String json = """ + {"values":["a","b"],"newField":99} + """; + McpSchema.CompleteResult.CompleteCompletion c = mapper.readValue(json, + McpSchema.CompleteResult.CompleteCompletion.class); + assertThat(c.values()).containsExactly("a", "b"); + } + + // ----------------------------------------------------------------------- + // LoggingLevel — lenient deserialization via @JsonCreator + // ----------------------------------------------------------------------- + + @Test + void loggingLevelDeserializesFromString() throws IOException { + String json = "\"warning\""; + McpSchema.LoggingLevel level = mapper.readValue(json, McpSchema.LoggingLevel.class); + assertThat(level).isEqualTo(McpSchema.LoggingLevel.WARNING); + } + + @Test + void loggingLevelUnknownValueReturnsNull() throws IOException { + String json = "\"nonexistent\""; + McpSchema.LoggingLevel level = mapper.readValue(json, McpSchema.LoggingLevel.class); + assertThat(level).isNull(); + } + + // ----------------------------------------------------------------------- + // ServerCapabilities nested records — unknown fields + // ----------------------------------------------------------------------- + + @Test + void serverCapabilitiesUnknownFieldsIgnored() throws IOException { + String json = """ + {"tools":{"listChanged":true,"futureField":"x"},"unknownCap":{}} + """; + McpSchema.ServerCapabilities caps = mapper.readValue(json, McpSchema.ServerCapabilities.class); + assertThat(caps.tools()).isNotNull(); + assertThat(caps.tools().listChanged()).isTrue(); + } + + // ----------------------------------------------------------------------- + // JSONRPCError + // ----------------------------------------------------------------------- + + @Test + void jsonRpcErrorUnknownFieldsIgnored() throws IOException { + String json = """ + {"code":-32601,"message":"Not found","futureData":{"detail":"x"}} + """; + McpSchema.JSONRPCResponse.JSONRPCError error = mapper.readValue(json, + McpSchema.JSONRPCResponse.JSONRPCError.class); + assertThat(error.code()).isEqualTo(-32601); + assertThat(error.message()).isEqualTo("Not found"); + } + +} From 5895b2edd2775aa6c35c9cbb09cf28fbc736fcd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Fri, 24 Apr 2026 14:05:12 +0200 Subject: [PATCH 8/9] fix: Return empty prompt completion result when prompt has no arguments (#934) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recent changes don't coerce null completion arguments to empty lists so we have to check for null when handling prompt completions. Resolves #932 Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../server/McpAsyncServer.java | 9 ++--- .../server/McpStatelessAsyncServer.java | 9 ++--- .../HttpServletStatelessIntegrationTests.java | 10 +++--- .../server/McpCompletionTests.java | 36 +++++++++++++++++-- 4 files changed, 45 insertions(+), 19 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index e5f57bad8..f7cb4d619 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -999,12 +999,9 @@ private McpRequestHandler completionCompleteRequestHan .message("Prompt not found: " + promptReference.name()) .build()); } - if (!promptSpec.prompt() - .arguments() - .stream() - .filter(arg -> arg.name().equals(argumentName)) - .findFirst() - .isPresent()) { + List arguments = promptSpec.prompt().arguments(); + if (arguments == null + || !arguments.stream().filter(arg -> arg.name().equals(argumentName)).findFirst().isPresent()) { logger.warn("Argument not found: {} in prompt: {}", argumentName, promptReference.name()); diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java index 18fc85786..46fdf0aab 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpStatelessAsyncServer.java @@ -744,12 +744,9 @@ private McpStatelessRequestHandler completionCompleteR .message("Prompt not found: " + promptReference.name()) .build()); } - if (!promptSpec.prompt() - .arguments() - .stream() - .filter(arg -> arg.name().equals(argumentName)) - .findFirst() - .isPresent()) { + List arguments = promptSpec.prompt().arguments(); + if (arguments == null + || !arguments.stream().filter(arg -> arg.name().equals(argumentName)).findFirst().isPresent()) { logger.warn("Argument not found: {} in prompt: {}", argumentName, promptReference.name()); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java index 3d40453a3..4a18fa1cd 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/HttpServletStatelessIntegrationTests.java @@ -184,10 +184,10 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) { true // hasMore )); - AtomicReference samplingRequest = new AtomicReference<>(); + AtomicReference completeRequest = new AtomicReference<>(); BiFunction completionHandler = (transportContext, request) -> { - samplingRequest.set(request); + completeRequest.set(request); return completionResponse; }; @@ -214,9 +214,9 @@ void testCompletionShouldReturnExpectedSuggestions(String clientType) { assertThat(result).isNotNull(); - assertThat(samplingRequest.get().argument().name()).isEqualTo("language"); - assertThat(samplingRequest.get().argument().value()).isEqualTo("py"); - assertThat(samplingRequest.get().ref().type()).isEqualTo(PromptReference.TYPE); + assertThat(completeRequest.get().argument().name()).isEqualTo("language"); + assertThat(completeRequest.get().argument().value()).isEqualTo("py"); + assertThat(completeRequest.get().ref().type()).isEqualTo(PromptReference.TYPE); } finally { mcpServer.close(); diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java index 54fb80a78..5a26402c7 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpCompletionTests.java @@ -13,11 +13,9 @@ import org.apache.catalina.LifecycleState; import org.apache.catalina.startup.Tomcat; -import static org.assertj.core.api.Assertions.assertThat; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import io.modelcontextprotocol.client.McpClient; import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; @@ -37,6 +35,9 @@ import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import io.modelcontextprotocol.spec.McpError; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + /** * Tests for completion functionality with context support. * @@ -324,4 +325,35 @@ void testCompletionErrorOnMissingContext() { mcpServer.close(); } + @Test + void testPromptWithoutArgumentsCompletionForArgument() { + BiFunction completionHandler = (exchange, + request) -> new CompleteResult(new CompleteResult.CompleteCompletion(List.of("test"), 1, false)); + + McpSchema.Prompt prompt = new Prompt("test-prompt", "this is a test prompt", null); + + var mcpServer = McpServer.sync(mcpServerTransportProvider) + .capabilities(ServerCapabilities.builder().completions().build()) + .prompts(new McpServerFeatures.SyncPromptSpecification(prompt, + (mcpSyncServerExchange, getPromptRequest) -> null)) + .completions(new McpServerFeatures.SyncCompletionSpecification( + new PromptReference(PromptReference.TYPE, "test-prompt"), completionHandler)) + .build(); + + try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0")) + .build()) { + InitializeResult initResult = mcpClient.initialize(); + assertThat(initResult).isNotNull(); + + // try completing an argument knowing that the prompt is not parameterized + CompleteRequest request = new CompleteRequest(new PromptReference(PromptReference.TYPE, "test-prompt"), + new CompleteRequest.CompleteArgument("arg", "val")); + + CompleteResult completeResult = mcpClient.completeCompletion(request); + assertThat(completeResult.completion().values()).isEmpty(); + } + + mcpServer.close(); + } + } \ No newline at end of file From 8d1f63ec08b58b5a2f8656a15b646a0719b4115a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= <2554306+chemicL@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:57:49 +0200 Subject: [PATCH 9/9] Remaining required fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk <2554306+chemicL@users.noreply.github.com> --- .../modelcontextprotocol/spec/McpSchema.java | 628 +++++++++++++++++- .../util/ToolInputValidator.java | 2 +- .../AsyncToolSpecificationBuilderTest.java | 8 +- .../SyncToolSpecificationBuilderTest.java | 8 +- .../util/ToolInputValidatorTests.java | 14 +- .../server/McpServerProtocolVersionTests.java | 3 +- .../server/ResourceSubscriptionTests.java | 4 +- .../spec/CompleteReferenceJsonTests.java | 16 + 8 files changed, 662 insertions(+), 21 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 00af949c4..eca5707f8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -355,6 +355,37 @@ public record InitializeRequest( // @formatter:off @JsonProperty("clientInfo") Implementation clientInfo, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public InitializeRequest { + Assert.notNull(protocolVersion, "protocolVersion must not be null"); + Assert.notNull(capabilities, "capabilities must not be null"); + Assert.notNull(clientInfo, "clientInfo must not be null"); + } + + @JsonCreator + static InitializeRequest fromJson(@JsonProperty("protocolVersion") String protocolVersion, + @JsonProperty("capabilities") ClientCapabilities capabilities, + @JsonProperty("clientInfo") Implementation clientInfo, + @JsonProperty("_meta") Map meta) { + if (protocolVersion == null || capabilities == null || clientInfo == null) { + List missing = new ArrayList<>(); + if (protocolVersion == null) { + missing.add("protocolVersion -> ''"); + protocolVersion = ""; + } + if (capabilities == null) { + missing.add("capabilities -> {}"); + capabilities = new ClientCapabilities(null, null, null, null); + } + if (clientInfo == null) { + missing.add("clientInfo -> {name='', version=''}"); + clientInfo = new Implementation("", ""); + } + logger.warn("InitializeRequest: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new InitializeRequest(protocolVersion, capabilities, clientInfo, meta); + } + public InitializeRequest(String protocolVersion, ClientCapabilities capabilities, Implementation clientInfo) { this(protocolVersion, capabilities, clientInfo, null); } @@ -384,6 +415,37 @@ public record InitializeResult( // @formatter:off @JsonProperty("instructions") String instructions, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public InitializeResult { + Assert.notNull(protocolVersion, "protocolVersion must not be null"); + Assert.notNull(capabilities, "capabilities must not be null"); + Assert.notNull(serverInfo, "serverInfo must not be null"); + } + + @JsonCreator + static InitializeResult fromJson(@JsonProperty("protocolVersion") String protocolVersion, + @JsonProperty("capabilities") ServerCapabilities capabilities, + @JsonProperty("serverInfo") Implementation serverInfo, + @JsonProperty("instructions") String instructions, @JsonProperty("_meta") Map meta) { + if (protocolVersion == null || capabilities == null || serverInfo == null) { + List missing = new ArrayList<>(); + if (protocolVersion == null) { + missing.add("protocolVersion -> ''"); + protocolVersion = ""; + } + if (capabilities == null) { + missing.add("capabilities -> {}"); + capabilities = new ServerCapabilities(null, null, null, null, null, null); + } + if (serverInfo == null) { + missing.add("serverInfo -> {name='', version=''}"); + serverInfo = new Implementation("", ""); + } + logger.warn("InitializeResult: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new InitializeResult(protocolVersion, capabilities, serverInfo, instructions, meta); + } + public InitializeResult(String protocolVersion, ServerCapabilities capabilities, Implementation serverInfo, String instructions) { this(protocolVersion, capabilities, serverInfo, instructions, null); @@ -702,7 +764,31 @@ public ServerCapabilities build() { public record Implementation( // @formatter:off @JsonProperty("name") String name, @JsonProperty("title") String title, - @JsonProperty("version") String version) implements Identifier { // @formatter:on + @JsonProperty("version") String version) implements Identifier { // @formatter:on + + public Implementation { + Assert.notNull(name, "name must not be null"); + Assert.notNull(version, "version must not be null"); + } + + @JsonCreator + static Implementation fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, + @JsonProperty("version") String version) { + if (name == null || version == null) { + List missing = new ArrayList<>(); + if (name == null) { + missing.add("name -> ''"); + name = ""; + } + if (version == null) { + missing.add("version -> ''"); + version = ""; + } + logger.warn("Implementation: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new Implementation(name, title, version); + } public Implementation(String name, String version) { this(name, null, version); @@ -1020,6 +1106,21 @@ public record ListResourcesResult( // @formatter:off @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListResourcesResult { + Assert.notNull(resources, "resources must not be null"); + } + + @JsonCreator + static ListResourcesResult fromJson(@JsonProperty("resources") List resources, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (resources == null) { + logger.warn( + "ListResourcesResult: missing required field 'resources' during deserialization, using default []"); + resources = List.of(); + } + return new ListResourcesResult(resources, nextCursor, meta); + } + public ListResourcesResult(List resources, String nextCursor) { this(resources, nextCursor, null); } @@ -1040,6 +1141,22 @@ public record ListResourceTemplatesResult( // @formatter:off @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListResourceTemplatesResult { + Assert.notNull(resourceTemplates, "resourceTemplates must not be null"); + } + + @JsonCreator + static ListResourceTemplatesResult fromJson( + @JsonProperty("resourceTemplates") List resourceTemplates, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (resourceTemplates == null) { + logger.warn( + "ListResourceTemplatesResult: missing required field 'resourceTemplates' during deserialization, using default []"); + resourceTemplates = List.of(); + } + return new ListResourceTemplatesResult(resourceTemplates, nextCursor, meta); + } + public ListResourceTemplatesResult(List resourceTemplates, String nextCursor) { this(resourceTemplates, nextCursor, null); } @@ -1058,6 +1175,21 @@ public record ReadResourceRequest( // @formatter:off @JsonProperty("uri") String uri, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public ReadResourceRequest { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static ReadResourceRequest fromJson(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger + .warn("ReadResourceRequest: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new ReadResourceRequest(uri, meta); + } + public ReadResourceRequest(String uri) { this(uri, null); } @@ -1075,6 +1207,21 @@ public record ReadResourceResult( // @formatter:off @JsonProperty("contents") List contents, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ReadResourceResult { + Assert.notNull(contents, "contents must not be null"); + } + + @JsonCreator + static ReadResourceResult fromJson(@JsonProperty("contents") List contents, + @JsonProperty("_meta") Map meta) { + if (contents == null) { + logger.warn( + "ReadResourceResult: missing required field 'contents' during deserialization, using default []"); + contents = List.of(); + } + return new ReadResourceResult(contents, meta); + } + public ReadResourceResult(List contents) { this(contents, null); } @@ -1094,6 +1241,20 @@ public record SubscribeRequest( // @formatter:off @JsonProperty("uri") String uri, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public SubscribeRequest { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static SubscribeRequest fromJson(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger.warn("SubscribeRequest: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new SubscribeRequest(uri, meta); + } + public SubscribeRequest(String uri) { this(uri, null); } @@ -1112,6 +1273,21 @@ public record UnsubscribeRequest( // @formatter:off @JsonProperty("uri") String uri, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public UnsubscribeRequest { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static UnsubscribeRequest fromJson(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger + .warn("UnsubscribeRequest: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new UnsubscribeRequest(uri, meta); + } + public UnsubscribeRequest(String uri) { this(uri, null); } @@ -1156,6 +1332,30 @@ public record TextResourceContents( // @formatter:off @JsonProperty("text") String text, @JsonProperty("_meta") Map meta) implements ResourceContents { // @formatter:on + public TextResourceContents { + Assert.notNull(uri, "uri must not be null"); + Assert.notNull(text, "text must not be null"); + } + + @JsonCreator + static TextResourceContents fromJson(@JsonProperty("uri") String uri, @JsonProperty("mimeType") String mimeType, + @JsonProperty("text") String text, @JsonProperty("_meta") Map meta) { + if (uri == null || text == null) { + List missing = new ArrayList<>(); + if (uri == null) { + missing.add("uri -> ''"); + uri = ""; + } + if (text == null) { + missing.add("text -> ''"); + text = ""; + } + logger.warn("TextResourceContents: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new TextResourceContents(uri, mimeType, text, meta); + } + public TextResourceContents(String uri, String mimeType, String text) { this(uri, mimeType, text, null); } @@ -1179,6 +1379,30 @@ public record BlobResourceContents( // @formatter:off @JsonProperty("blob") String blob, @JsonProperty("_meta") Map meta) implements ResourceContents { // @formatter:on + public BlobResourceContents { + Assert.notNull(uri, "uri must not be null"); + Assert.notNull(blob, "blob must not be null"); + } + + @JsonCreator + static BlobResourceContents fromJson(@JsonProperty("uri") String uri, @JsonProperty("mimeType") String mimeType, + @JsonProperty("blob") String blob, @JsonProperty("_meta") Map meta) { + if (uri == null || blob == null) { + List missing = new ArrayList<>(); + if (uri == null) { + missing.add("uri -> ''"); + uri = ""; + } + if (blob == null) { + missing.add("blob -> ''"); + blob = ""; + } + logger.warn("BlobResourceContents: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new BlobResourceContents(uri, mimeType, blob, meta); + } + public BlobResourceContents(String uri, String mimeType, String blob) { this(uri, mimeType, blob, null); } @@ -1205,6 +1429,22 @@ public record Prompt( // @formatter:off @JsonProperty("arguments") List arguments, @JsonProperty("_meta") Map meta) implements Identifier { // @formatter:on + public Prompt { + Assert.notNull(name, "name must not be null"); + } + + @JsonCreator + static Prompt fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("arguments") List arguments, + @JsonProperty("_meta") Map meta) { + if (name == null) { + logger.warn("Prompt: missing required field 'name' during deserialization, using default ''"); + name = ""; + } + return new Prompt(name, title, description, arguments, meta); + } + public Prompt(String name, String description, List arguments) { this(name, null, description, arguments, null); } @@ -1249,6 +1489,29 @@ public PromptArgument(String name, String description, Boolean required) { public record PromptMessage( // @formatter:off @JsonProperty("role") Role role, @JsonProperty("content") Content content) { // @formatter:on + + public PromptMessage { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + } + + @JsonCreator + static PromptMessage fromJson(@JsonProperty("role") Role role, @JsonProperty("content") Content content) { + if (role == null || content == null) { + List missing = new ArrayList<>(); + if (role == null) { + missing.add("role -> 'user'"); + role = Role.USER; + } + if (content == null) { + missing.add("content -> ''"); + content = new TextContent(""); + } + logger.warn("PromptMessage: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new PromptMessage(role, content); + } } /** @@ -1266,6 +1529,21 @@ public record ListPromptsResult( // @formatter:off @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListPromptsResult { + Assert.notNull(prompts, "prompts must not be null"); + } + + @JsonCreator + static ListPromptsResult fromJson(@JsonProperty("prompts") List prompts, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (prompts == null) { + logger.warn( + "ListPromptsResult: missing required field 'prompts' during deserialization, using default []"); + prompts = List.of(); + } + return new ListPromptsResult(prompts, nextCursor, meta); + } + public ListPromptsResult(List prompts, String nextCursor) { this(prompts, nextCursor, null); } @@ -1285,6 +1563,21 @@ public record GetPromptRequest( // @formatter:off @JsonProperty("arguments") Map arguments, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public GetPromptRequest { + Assert.notNull(name, "name must not be null"); + } + + @JsonCreator + static GetPromptRequest fromJson(@JsonProperty("name") String name, + @JsonProperty("arguments") Map arguments, + @JsonProperty("_meta") Map meta) { + if (name == null) { + logger.warn("GetPromptRequest: missing required field 'name' during deserialization, using default ''"); + name = ""; + } + return new GetPromptRequest(name, arguments, meta); + } + public GetPromptRequest(String name, Map arguments) { this(name, arguments, null); } @@ -1304,6 +1597,22 @@ public record GetPromptResult( // @formatter:off @JsonProperty("messages") List messages, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public GetPromptResult { + Assert.notNull(messages, "messages must not be null"); + } + + @JsonCreator + static GetPromptResult fromJson(@JsonProperty("description") String description, + @JsonProperty("messages") List messages, + @JsonProperty("_meta") Map meta) { + if (messages == null) { + logger.warn( + "GetPromptResult: missing required field 'messages' during deserialization, using default []"); + messages = List.of(); + } + return new GetPromptResult(description, messages, meta); + } + public GetPromptResult(String description, List messages) { this(description, messages, null); } @@ -1327,6 +1636,20 @@ public record ListToolsResult( // @formatter:off @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListToolsResult { + Assert.notNull(tools, "tools must not be null"); + } + + @JsonCreator + static ListToolsResult fromJson(@JsonProperty("tools") List tools, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (tools == null) { + logger.warn("ListToolsResult: missing required field 'tools' during deserialization, using default []"); + tools = List.of(); + } + return new ListToolsResult(tools, nextCursor, meta); + } + public ListToolsResult(List tools, String nextCursor) { this(tools, nextCursor, null); } @@ -1404,10 +1727,45 @@ public record Tool( // @formatter:off @JsonProperty("annotations") ToolAnnotations annotations, @JsonProperty("_meta") Map meta) { // @formatter:on + public Tool { + Assert.notNull(name, "name must not be null"); + Assert.notNull(inputSchema, "inputSchema must not be null"); + } + + @JsonCreator + static Tool fromJson(@JsonProperty("name") String name, @JsonProperty("title") String title, + @JsonProperty("description") String description, + @JsonProperty("inputSchema") Map inputSchema, + @JsonProperty("outputSchema") Map outputSchema, + @JsonProperty("annotations") ToolAnnotations annotations, + @JsonProperty("_meta") Map meta) { + if (name == null || inputSchema == null) { + List missing = new ArrayList<>(); + if (name == null) { + missing.add("name -> ''"); + name = ""; + } + if (inputSchema == null) { + missing.add("inputSchema -> {}"); + inputSchema = Map.of(); + } + logger.warn("Tool: missing required fields during deserialization: {}", String.join(", ", missing)); + } + return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); + } + + /** + * @deprecated Use {@link #builder(String)} instead. + */ + @Deprecated public static Builder builder() { return new Builder(); } + public static Builder builder(String name) { + return new Builder(name); + } + public static class Builder { private String name; @@ -1424,6 +1782,18 @@ public static class Builder { private Map meta; + /** + * @deprecated Use {@link Tool#builder(String)} instead. + */ + @Deprecated + public Builder() { + } + + private Builder(String name) { + Assert.hasText(name, "name must not be empty"); + this.name = name; + } + public Builder name(String name) { this.name = name; return this; @@ -1492,6 +1862,9 @@ public Builder meta(Map meta) { public Tool build() { Assert.hasText(name, "name must not be empty"); + if (inputSchema == null) { + inputSchema = Map.of("type", "object"); + } return new Tool(name, title, description, inputSchema, outputSchema, annotations, meta); } @@ -1524,6 +1897,21 @@ public record CallToolRequest( // @formatter:off @JsonProperty("arguments") Map arguments, @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + public CallToolRequest { + Assert.notNull(name, "name must not be null"); + } + + @JsonCreator + static CallToolRequest fromJson(@JsonProperty("name") String name, + @JsonProperty("arguments") Map arguments, + @JsonProperty("_meta") Map meta) { + if (name == null) { + logger.warn("CallToolRequest: missing required field 'name' during deserialization, using default ''"); + name = ""; + } + return new CallToolRequest(name, arguments, meta); + } + public CallToolRequest(McpJsonMapper jsonMapper, String name, String jsonArguments) { this(name, parseJsonArguments(jsonMapper, jsonArguments), null); } @@ -1541,10 +1929,18 @@ private static Map parseJsonArguments(McpJsonMapper jsonMapper, } } + /** + * @deprecated Use {@link #builder(String)} instead. + */ + @Deprecated public static Builder builder() { return new Builder(); } + public static Builder builder(String name) { + return new Builder(name); + } + public static class Builder { private String name; @@ -1553,6 +1949,18 @@ public static class Builder { private Map meta; + /** + * @deprecated Use {@link CallToolRequest#builder(String)} instead. + */ + @Deprecated + public Builder() { + } + + private Builder(String name) { + Assert.hasText(name, "name must not be empty"); + this.name = name; + } + public Builder name(String name) { this.name = name; return this; @@ -1975,8 +2383,6 @@ public enum ContextInclusionStrategy { @JsonProperty("allServers") ALL_SERVERS } // @formatter:on - } - /** * @deprecated Use {@link #builder(List, int)} instead. */ @@ -2078,6 +2484,8 @@ public Builder progressToken(Object progressToken) { } public CreateMessageRequest build() { + Assert.notNull(messages, "messages must not be null"); + Assert.notNull(maxTokens, "maxTokens must not be null"); return new CreateMessageRequest(messages, modelPreferences, systemPrompt, includeContext, temperature, maxTokens, stopSequences, metadata, meta); } @@ -2106,6 +2514,36 @@ public record CreateMessageResult( // @formatter:off @JsonProperty("stopReason") StopReason stopReason, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public CreateMessageResult { + Assert.notNull(role, "role must not be null"); + Assert.notNull(content, "content must not be null"); + Assert.notNull(model, "model must not be null"); + } + + @JsonCreator + static CreateMessageResult fromJson(@JsonProperty("role") Role role, @JsonProperty("content") Content content, + @JsonProperty("model") String model, @JsonProperty("stopReason") StopReason stopReason, + @JsonProperty("_meta") Map meta) { + if (role == null || content == null || model == null) { + List missing = new ArrayList<>(); + if (role == null) { + missing.add("role -> 'assistant'"); + role = Role.ASSISTANT; + } + if (content == null) { + missing.add("content -> ''"); + content = new TextContent(""); + } + if (model == null) { + missing.add("model -> ''"); + model = ""; + } + logger.warn("CreateMessageResult: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new CreateMessageResult(role, content, model, stopReason, meta); + } + public enum StopReason { // @formatter:off @@ -2190,6 +2628,8 @@ public Builder meta(Map meta) { } public CreateMessageResult build() { + Assert.notNull(content, "content must not be null"); + Assert.notNull(model, "model must not be null"); return new CreateMessageResult(role, content, model, stopReason, meta); } @@ -2308,6 +2748,8 @@ public Builder progressToken(Object progressToken) { } public ElicitRequest build() { + Assert.notNull(message, "message must not be null"); + Assert.notNull(requestedSchema, "requestedSchema must not be null"); return new ElicitRequest(message, requestedSchema, meta); } @@ -2331,6 +2773,21 @@ public record ElicitResult( // @formatter:off @JsonProperty("content") Map content, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ElicitResult { + Assert.notNull(action, "action must not be null"); + } + + @JsonCreator + static ElicitResult fromJson(@JsonProperty("action") Action action, + @JsonProperty("content") Map content, @JsonProperty("_meta") Map meta) { + if (action == null) { + logger.warn( + "ElicitResult: missing required field 'action' during deserialization, using default 'cancel'"); + action = Action.CANCEL; + } + return new ElicitResult(action, content, meta); + } + public enum Action { // @formatter:off @@ -2372,6 +2829,7 @@ public Builder meta(Map meta) { } public ElicitResult build() { + Assert.notNull(action, "action must not be null"); return new ElicitResult(action, content, meta); } @@ -2533,6 +2991,21 @@ public record ResourcesUpdatedNotification(// @formatter:off @JsonProperty("uri") String uri, @JsonProperty("_meta") Map meta) implements Notification { // @formatter:on + public ResourcesUpdatedNotification { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static ResourcesUpdatedNotification fromJson(@JsonProperty("uri") String uri, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger.warn( + "ResourcesUpdatedNotification: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new ResourcesUpdatedNotification(uri, meta); + } + public ResourcesUpdatedNotification(String uri) { this(uri, null); } @@ -2652,6 +3125,7 @@ public Builder meta(Map meta) { } public LoggingMessageNotification build() { + Assert.notNull(data, "data must not be null"); return new LoggingMessageNotification(level, logger, data, meta); } @@ -2713,6 +3187,20 @@ public static LoggingLevel fromValue(String value) { @JsonInclude(JsonInclude.Include.NON_ABSENT) @JsonIgnoreProperties(ignoreUnknown = true) public record SetLevelRequest(@JsonProperty("level") LoggingLevel level) { + + public SetLevelRequest { + Assert.notNull(level, "level must not be null"); + } + + @JsonCreator + static SetLevelRequest fromJson(@JsonProperty("level") LoggingLevel level) { + if (level == null) { + logger.warn( + "SetLevelRequest: missing required field 'level' during deserialization, using default 'info'"); + level = LoggingLevel.INFO; + } + return new SetLevelRequest(level); + } } // --------------------------- @@ -2822,6 +3310,18 @@ public record CompleteRequest( // @formatter:off @JsonProperty("_meta") Map meta, @JsonProperty("context") CompleteContext context) implements Request { // @formatter:on + public CompleteRequest { + Assert.notNull(ref, "ref must not be null"); + Assert.notNull(argument, "argument must not be null"); + } + + @JsonCreator + static CompleteRequest fromJson(@JsonProperty("ref") McpSchema.CompleteReference ref, + @JsonProperty("argument") CompleteArgument argument, @JsonProperty("_meta") Map meta, + @JsonProperty("context") CompleteContext context) { + return new CompleteRequest(ref, argument, meta, context); + } + public CompleteRequest(McpSchema.CompleteReference ref, CompleteArgument argument, Map meta) { this(ref, argument, meta, null); } @@ -2868,6 +3368,21 @@ public record CompleteResult(// @formatter:off @JsonProperty("completion") CompleteCompletion completion, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public CompleteResult { + Assert.notNull(completion, "completion must not be null"); + } + + @JsonCreator + static CompleteResult fromJson(@JsonProperty("completion") CompleteCompletion completion, + @JsonProperty("_meta") Map meta) { + if (completion == null) { + logger.warn( + "CompleteResult: missing required field 'completion' during deserialization, using default {values=[]}"); + completion = new CompleteCompletion(List.of(), null, null); + } + return new CompleteResult(completion, meta); + } + // backwards compatibility constructor public CompleteResult(CompleteCompletion completion) { this(completion, null); @@ -2947,6 +3462,20 @@ public record TextContent( // @formatter:off @JsonProperty("text") String text, @JsonProperty("_meta") Map meta) implements Annotated, Content { // @formatter:on + public TextContent { + Assert.notNull(text, "text must not be null"); + } + + @JsonCreator + static TextContent fromJson(@JsonProperty("annotations") Annotations annotations, + @JsonProperty("text") String text, @JsonProperty("_meta") Map meta) { + if (text == null) { + logger.warn("TextContent: missing required field 'text' during deserialization, using default ''"); + text = ""; + } + return new TextContent(annotations, text, meta); + } + public TextContent(Annotations annotations, String text) { this(annotations, text, null); } @@ -2973,6 +3502,31 @@ public record ImageContent( // @formatter:off @JsonProperty("mimeType") String mimeType, @JsonProperty("_meta") Map meta) implements Annotated, Content { // @formatter:on + public ImageContent { + Assert.notNull(data, "data must not be null"); + Assert.notNull(mimeType, "mimeType must not be null"); + } + + @JsonCreator + static ImageContent fromJson(@JsonProperty("annotations") Annotations annotations, + @JsonProperty("data") String data, @JsonProperty("mimeType") String mimeType, + @JsonProperty("_meta") Map meta) { + if (data == null || mimeType == null) { + List missing = new ArrayList<>(); + if (data == null) { + missing.add("data -> ''"); + data = ""; + } + if (mimeType == null) { + missing.add("mimeType -> ''"); + mimeType = ""; + } + logger.warn("ImageContent: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new ImageContent(annotations, data, mimeType, meta); + } + public ImageContent(Annotations annotations, String data, String mimeType) { this(annotations, data, mimeType, null); } @@ -2995,6 +3549,31 @@ public record AudioContent( // @formatter:off @JsonProperty("mimeType") String mimeType, @JsonProperty("_meta") Map meta) implements Annotated, Content { // @formatter:on + public AudioContent { + Assert.notNull(data, "data must not be null"); + Assert.notNull(mimeType, "mimeType must not be null"); + } + + @JsonCreator + static AudioContent fromJson(@JsonProperty("annotations") Annotations annotations, + @JsonProperty("data") String data, @JsonProperty("mimeType") String mimeType, + @JsonProperty("_meta") Map meta) { + if (data == null || mimeType == null) { + List missing = new ArrayList<>(); + if (data == null) { + missing.add("data -> ''"); + data = ""; + } + if (mimeType == null) { + missing.add("mimeType -> ''"); + mimeType = ""; + } + logger.warn("AudioContent: missing required fields during deserialization: {}", + String.join(", ", missing)); + } + return new AudioContent(annotations, data, mimeType, meta); + } + // backwards compatibility constructor public AudioContent(Annotations annotations, String data, String mimeType) { this(annotations, data, mimeType, null); @@ -3018,6 +3597,21 @@ public record EmbeddedResource( // @formatter:off @JsonProperty("resource") ResourceContents resource, @JsonProperty("_meta") Map meta) implements Annotated, Content { // @formatter:on + public EmbeddedResource { + Assert.notNull(resource, "resource must not be null"); + } + + @JsonCreator + static EmbeddedResource fromJson(@JsonProperty("annotations") Annotations annotations, + @JsonProperty("resource") ResourceContents resource, @JsonProperty("_meta") Map meta) { + if (resource == null) { + logger.warn( + "EmbeddedResource: missing required field 'resource' during deserialization, using empty text resource"); + resource = new TextResourceContents("", null, ""); + } + return new EmbeddedResource(annotations, resource, meta); + } + // backwards compatibility constructor public EmbeddedResource(Annotations annotations, ResourceContents resource) { this(annotations, resource, null); @@ -3147,6 +3741,20 @@ public record Root( // @formatter:off @JsonProperty("name") String name, @JsonProperty("_meta") Map meta) { // @formatter:on + public Root { + Assert.notNull(uri, "uri must not be null"); + } + + @JsonCreator + static Root fromJson(@JsonProperty("uri") String uri, @JsonProperty("name") String name, + @JsonProperty("_meta") Map meta) { + if (uri == null) { + logger.warn("Root: missing required field 'uri' during deserialization, using default ''"); + uri = ""; + } + return new Root(uri, name, meta); + } + public Root(String uri, String name) { this(uri, name, null); } @@ -3171,6 +3779,20 @@ public record ListRootsResult( // @formatter:off @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) implements Result { // @formatter:on + public ListRootsResult { + Assert.notNull(roots, "roots must not be null"); + } + + @JsonCreator + static ListRootsResult fromJson(@JsonProperty("roots") List roots, + @JsonProperty("nextCursor") String nextCursor, @JsonProperty("_meta") Map meta) { + if (roots == null) { + logger.warn("ListRootsResult: missing required field 'roots' during deserialization, using default []"); + roots = List.of(); + } + return new ListRootsResult(roots, nextCursor, meta); + } + public ListRootsResult(List roots) { this(roots, null); } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java index d3db7fb4b..d85544074 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/util/ToolInputValidator.java @@ -36,7 +36,7 @@ private ToolInputValidator() { */ public static CallToolResult validate(McpSchema.Tool tool, Map arguments, boolean validateToolInputs, JsonSchemaValidator validator) { - if (!validateToolInputs || tool.inputSchema() == null || validator == null) { + if (!validateToolInputs || tool.inputSchema() == null || tool.inputSchema().isEmpty() || validator == null) { return null; } Map args = arguments != null ? arguments : Map.of(); diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java index ee8c70ffe..ba283dd86 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/AsyncToolSpecificationBuilderTest.java @@ -193,7 +193,7 @@ void tearDown() { @Test void defaultShouldThrowOnInvalidName() { - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder().name("invalid tool name").inputSchema(EMPTY_JSON_SCHEMA).build(); assertThatThrownBy( () -> McpServer.async(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) @@ -204,7 +204,7 @@ void defaultShouldThrowOnInvalidName() { @Test void lenientDefaultShouldLogOnInvalidName() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder().name("invalid tool name").inputSchema(EMPTY_JSON_SCHEMA).build(); assertThatCode(() -> McpServer.async(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) .doesNotThrowAnyException(); @@ -213,7 +213,7 @@ void lenientDefaultShouldLogOnInvalidName() { @Test void lenientConfigurationShouldLogOnInvalidName() { - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder().name("invalid tool name").inputSchema(EMPTY_JSON_SCHEMA).build(); assertThatCode(() -> McpServer.async(transportProvider) .strictToolNameValidation(false) @@ -224,7 +224,7 @@ void lenientConfigurationShouldLogOnInvalidName() { @Test void serverConfigurationShouldOverrideDefault() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder().name("invalid tool name").inputSchema(EMPTY_JSON_SCHEMA).build(); assertThatThrownBy(() -> McpServer.async(transportProvider) .strictToolNameValidation(true) diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java index f7364be2d..56f182de2 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/SyncToolSpecificationBuilderTest.java @@ -138,7 +138,7 @@ void tearDown() { @Test void defaultShouldThrowOnInvalidName() { - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder().name("invalid tool name").inputSchema(EMPTY_JSON_SCHEMA).build(); assertThatThrownBy( () -> McpServer.sync(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) @@ -149,7 +149,7 @@ void defaultShouldThrowOnInvalidName() { @Test void lenientDefaultShouldLogOnInvalidName() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder().name("invalid tool name").inputSchema(EMPTY_JSON_SCHEMA).build(); assertThatCode(() -> McpServer.sync(transportProvider).toolCall(invalidTool, (exchange, request) -> null)) .doesNotThrowAnyException(); @@ -158,7 +158,7 @@ void lenientDefaultShouldLogOnInvalidName() { @Test void lenientConfigurationShouldLogOnInvalidName() { - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder().name("invalid tool name").inputSchema(EMPTY_JSON_SCHEMA).build(); assertThatCode(() -> McpServer.sync(transportProvider) .strictToolNameValidation(false) @@ -169,7 +169,7 @@ void lenientConfigurationShouldLogOnInvalidName() { @Test void serverConfigurationShouldOverrideDefault() { System.setProperty(ToolNameValidator.STRICT_VALIDATION_PROPERTY, "false"); - Tool invalidTool = Tool.builder().name("invalid tool name").build(); + Tool invalidTool = Tool.builder().name("invalid tool name").inputSchema(EMPTY_JSON_SCHEMA).build(); assertThatThrownBy(() -> McpServer.sync(transportProvider) .strictToolNameValidation(true) diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java index 4d073d1a7..f845054de 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/util/ToolInputValidatorTests.java @@ -16,10 +16,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; /** * Tests for {@link ToolInputValidator}. @@ -39,7 +36,10 @@ class ToolInputValidatorTests { .inputSchema(inputSchema) .build(); - private final Tool toolWithoutSchema = Tool.builder().name("test-tool").description("Test tool").build(); + private final Tool toolWithoutSchema = Tool.builder() + .name("test-tool") + .description("Test tool") + .build(); @Test void validate_whenDisabled_returnsNull() { @@ -51,10 +51,12 @@ void validate_whenDisabled_returnsNull() { @Test void validate_whenNoSchema_returnsNull() { + when(validator.validate(any(), any())).thenReturn(ValidationResponse.asValid(null)); + CallToolResult result = ToolInputValidator.validate(toolWithoutSchema, Map.of("name", "test"), true, validator); assertThat(result).isNull(); - verify(validator, never()).validate(any(), any()); + verify(validator).validate(any(), any()); } @Test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java index d9f899020..da136b407 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/McpServerProtocolVersionTests.java @@ -26,7 +26,8 @@ class McpServerProtocolVersionTests { private McpSchema.JSONRPCRequest jsonRpcInitializeRequest(String requestId, String protocolVersion) { return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, requestId, - new McpSchema.InitializeRequest(protocolVersion, null, CLIENT_INFO)); + new McpSchema.InitializeRequest(protocolVersion, McpSchema.ClientCapabilities.builder().build(), + CLIENT_INFO)); } @Test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java index 016e25e9f..63ee14833 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/server/ResourceSubscriptionTests.java @@ -37,8 +37,8 @@ private static McpAsyncServer buildServer(MockMcpServerTransportProvider transpo private static McpSchema.JSONRPCRequest initRequest() { return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, - UUID.randomUUID().toString(), - new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, null, CLIENT_INFO)); + UUID.randomUUID().toString(), new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_11_25, + McpSchema.ClientCapabilities.builder().build(), CLIENT_INFO)); } private static McpSchema.JSONRPCNotification initializedNotification() { diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java index 1b23c5059..874b995e1 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/CompleteReferenceJsonTests.java @@ -7,12 +7,14 @@ import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.io.IOException; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.json.TypeRef; import org.junit.jupiter.api.Test; +import tools.jackson.databind.exc.ValueInstantiationException; /** * Verifies that {@link McpSchema.CompleteReference} polymorphic dispatch works via direct @@ -82,6 +84,20 @@ void completeRequestConvertValueFromMapDispatchesPromptRef() throws IOException assertThat(req.ref().identifier()).isEqualTo("my-prompt"); } + @Test + void completeRequestMissingRefFailsToInstantiate() throws IOException { + String json = """ + {"argument":{"name":"lang","value":"java"}} + """; + + // This is the real in-process path: params arrives as a Map from JSON-RPC + Object paramsMap = mapper.readValue(json, Object.class); + + assertThatThrownBy(() -> mapper.convertValue(paramsMap, new TypeRef() { + })).isInstanceOf(ValueInstantiationException.class).hasMessageContaining("ref must not be null"); + + } + @Test void typeDiscriminatorAppearsExactlyOnce() throws IOException { McpSchema.PromptReference ref = new McpSchema.PromptReference("p");