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)); + } +}
