This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git

commit e8dacf970ae94dd939cc96212cfbff798bf92886
Author: James Bognar <[email protected]>
AuthorDate: Tue Jul 28 18:26:02 2026 -0400

    feat: reject tool schemas the 2025-06-18 wire format cannot represent
    
    The neutral McpSchema is an unconstrained JSON object carrier while this 
revision's
    JsonSchema bean supports exactly six keywords, so a config using anything 
else is
    rejected on first dispatch naming both the tool and the keyword, rather than
    silently dropping it on the wire. Additive: such a schema was not 
constructible
    through the pre-re-layering public API.
    
    Co-authored-by: Cursor <[email protected]>
---
 .../server/mcp/v20250618/Mcp20250618Revision.java  |  56 +++++++++++
 .../server/mcp/v20250618/McpSchemaCheck_Test.java  | 106 +++++++++++++++++++++
 2 files changed, 162 insertions(+)

diff --git 
a/juneau-rest/juneau-rest-server-mcp-2025-06-18/src/main/java/org/apache/juneau/rest/server/mcp/v20250618/Mcp20250618Revision.java
 
b/juneau-rest/juneau-rest-server-mcp-2025-06-18/src/main/java/org/apache/juneau/rest/server/mcp/v20250618/Mcp20250618Revision.java
index 128e7a0f6e..8d886b4571 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-2025-06-18/src/main/java/org/apache/juneau/rest/server/mcp/v20250618/Mcp20250618Revision.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-2025-06-18/src/main/java/org/apache/juneau/rest/server/mcp/v20250618/Mcp20250618Revision.java
@@ -79,6 +79,59 @@ public final class Mcp20250618Revision implements 
McpRevision {
        /** Default server name reported by {@code initialize} when the config 
supplies no server identity. */
        public static final String DEFAULT_SERVER_NAME = 
"juneau-rest-server-mcp";
 
+       private static final Set<String> SUPPORTED_SCHEMA_KEYWORDS =
+               Set.of("type", "properties", "required", 
"additionalProperties", "items", "$defs");
+
+       private static final Set<McpServerConfig> VALIDATED =
+               Collections.synchronizedSet(Collections.newSetFromMap(new 
WeakHashMap<>()));
+
+       /**
+        * Verifies that every registered tool's input schema is expressible in 
this revision's wire
+        * schema type.
+        *
+        * <p>
+        * The neutral {@link McpSchema} is an unconstrained JSON object 
carrier, but this revision's
+        * {@code JsonSchema} bean supports exactly six keywords. Rather than 
silently dropping an
+        * unsupported keyword on the wire, a config carrying one is rejected, 
naming both the tool and
+        * the keyword.
+        *
+        * <p>
+        * Note this cannot be a complete guarantee against every possible wire 
shape: {@code JsonSchema}
+        * is {@code public} and non-{@code final}, and Juneau's 
reflection-based marshalling will
+        * serialize a getter added by a subclass. No such subclass exists in 
this codebase, and none can
+        * be constructed through the public MCP API, but the check covers what 
a caller can express
+        * through {@link McpSchema}, not what reflection can reach.
+        *
+        * @param config The config to validate. Never {@code null}.
+        * @throws IllegalArgumentException If any tool's schema uses an 
unsupported keyword.
+        */
+       public static void validateSchemas(McpServerConfig config) {
+               config.getTools().forEach(x -> {
+                       var spec = x.descriptor();
+                       if (spec != null && spec.getInputSchema() != null)
+                               checkKeywords(spec.getName(), 
spec.getInputSchema().toJsonMap());
+               });
+       }
+
+       private static void checkKeywords(String toolName, Map<String,Object> 
schema) {
+               schema.forEach((k, v) -> {
+                       if (! SUPPORTED_SCHEMA_KEYWORDS.contains(k))
+                               throw iaex("Tool ''%s'' declares JSON Schema 
keyword ''%s'', which MCP revision 2025-06-18 cannot represent.", toolName, k);
+                       if (v instanceof Map<?,?> v2)
+                               v2.forEach((k2, v3) -> {
+                                       if (v3 instanceof Map<?,?> v4)
+                                               checkKeywords(toolName, 
asStringKeyed(v4));
+                               });
+               });
+       }
+
+       @SuppressWarnings({
+               "unchecked" // Cast is safe: JSON object keys are always 
strings.
+       })
+       private static Map<String,Object> asStringKeyed(Map<?,?> x) {
+               return (Map<String,Object>) x;
+       }
+
        @Override /* McpRevision */
        public String protocolVersion() {
                return McpProtocol.VERSION_2025_06_18;
@@ -101,6 +154,9 @@ public final class Mcp20250618Revision implements 
McpRevision {
                assertArgNotNull("config", config);
                assertArgNotNull("ctx", ctx);
 
+               if (VALIDATED.add(config))
+                       validateSchemas(config);
+
                var req = exchange.request();
                if (req == null)
                        return JsonRpcResponse.errorResponse(null, 
errorCode(McpErrorKind.INVALID_REQUEST), "Request envelope is null");
diff --git 
a/juneau-rest/juneau-rest-server-mcp-2025-06-18/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpSchemaCheck_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-2025-06-18/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpSchemaCheck_Test.java
new file mode 100644
index 0000000000..ad101ae174
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-mcp-2025-06-18/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpSchemaCheck_Test.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.mcp.v20250618;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.marshall.collections.*;
+import org.apache.juneau.rest.server.mcp.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for the {@code 2025-06-18} schema-capability check.
+ *
+ * <p>
+ * This revision's wire schema bean supports exactly six keywords. A neutral 
{@link McpSchema} can
+ * carry anything, so a config using a keyword this revision cannot express is 
rejected on first
+ * dispatch rather than silently dropping the keyword on the wire.
+ */
+class McpSchemaCheck_Test {
+
+       private final BeanStore ctx = new BasicBeanStore();
+
+       private static McpToolHandler tool(String name, JsonMap schema) {
+               return new McpToolHandler() {
+                       @Override public McpToolSpec descriptor() {
+                               return new 
McpToolSpec().setName(name).setInputSchema(schema == null ? null : 
McpSchema.of(schema));
+                       }
+                       @Override public McpToolOutcome call(Map<String,Object> 
arguments, BeanStore ctx2) { return new McpToolOutcome(); }
+               };
+       }
+
+       private static McpExchange ping() {
+               return new McpExchange(new 
org.apache.juneau.bean.jsonrpc.JsonRpcRequest().setId(1).setMethod("ping"), n 
-> null);
+       }
+
+       @Test
+       void a01_unsupportedTopLevelKeyword_isRejectedNamingToolAndKeyword() {
+               var config = new McpServerConfig().addTool(tool("risky", 
JsonMap.of("type", "object", "oneOf", List.of())));
+               var e = assertThrows(IllegalArgumentException.class,
+                       () -> new Mcp20250618Revision(null).dispatch(ping(), 
config, ctx));
+               assertTrue(e.getMessage().contains("risky"), e.getMessage());
+               assertTrue(e.getMessage().contains("oneOf"), e.getMessage());
+               assertTrue(e.getMessage().contains("2025-06-18"), 
e.getMessage());
+       }
+
+       @Test
+       void a02_unsupportedNestedKeyword_isRejected() {
+               var nested = JsonMap.of("type", "object", "properties", 
JsonMap.of("a", JsonMap.of("$ref", "#/$defs/X")));
+               var config = new McpServerConfig().addTool(tool("nested", 
nested));
+               var e = assertThrows(IllegalArgumentException.class,
+                       () -> new Mcp20250618Revision(null).dispatch(ping(), 
config, ctx));
+               assertTrue(e.getMessage().contains("nested"), e.getMessage());
+               assertTrue(e.getMessage().contains("$ref"), e.getMessage());
+       }
+
+       @Test
+       void a03_everySupportedKeyword_startsClean() {
+               var schema = JsonMap.of(
+                       "type", "object",
+                       "required", List.of("id"),
+                       "properties", JsonMap.of("id", JsonMap.of("type", 
"string")),
+                       "items", JsonMap.of("type", "string"),
+                       "additionalProperties", false,
+                       "$defs", JsonMap.of("IdString", JsonMap.of("type", 
"string")));
+               var config = new McpServerConfig().addTool(tool("ok", schema));
+               assertNotNull(new Mcp20250618Revision(null).dispatch(ping(), 
config, ctx));
+       }
+
+       @Test
+       void a04_nullAndEmptySchemas_startClean() {
+               var config = new McpServerConfig().addTool(tool("noSchema", 
null)).addTool(tool("emptySchema", new JsonMap()));
+               assertNotNull(new Mcp20250618Revision(null).dispatch(ping(), 
config, ctx));
+       }
+
+       @Test
+       void a05_checkRunsOncePerConfigInstance() {
+               // Deliberately two distinct Mcp20250618Revision instances (C8: 
no shared INSTANCE anymore) —
+               // this also proves VALIDATED is keyed by config identity, not 
by revision instance identity.
+               var config = new McpServerConfig().addTool(tool("ok", 
JsonMap.of("type", "object")));
+               assertNotNull(new Mcp20250618Revision(null).dispatch(ping(), 
config, ctx));
+               assertNotNull(new Mcp20250618Revision(null).dispatch(ping(), 
config, ctx));
+       }
+
+       @Test
+       void a06_validateSchemas_isDirectlyCallable() {
+               var bad = new McpServerConfig().addTool(tool("risky", 
JsonMap.of("enum", List.of("a"))));
+               assertThrows(IllegalArgumentException.class, () -> 
Mcp20250618Revision.validateSchemas(bad));
+       }
+}

Reply via email to