You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/10/26 22:23:08 UTC

(juneau) branch master updated: Drop the wall-clock deadline from MCP JSON safety checks (new JsonValueSafety.NO_DEADLINE): server-authored schemas, tool input and structured output are node-bounded, so a slow host no longer rejects valid configs or results. Mark an MCP config validated only after its schemas pass, so a bad config keeps failing, and restore the input schema in the serializer-parity tests.

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


The following commit(s) were added to refs/heads/master by this push:
     new 455b4d34d7 Drop the wall-clock deadline from MCP JSON safety checks (new JsonValueSafety.NO_DEADLINE): server-authored schemas, tool input and structured output are node-bounded, so a slow host no longer rejects valid configs or results. Mark an MCP config validated only after its schemas pass, so a bad config keeps failing, and restore the input schema in the serializer-parity tests.
455b4d34d7 is described below

commit 455b4d34d7e48c4d6f7efe886996ec2c8d2ca40c
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Oct 26 18:23:01 2026 -0400

    Drop the wall-clock deadline from MCP JSON safety checks (new JsonValueSafety.NO_DEADLINE): server-authored schemas, tool input and structured output are node-bounded, so a slow host no longer rejects valid configs or results. Mark an MCP config validated only after its schemas pass, so a bad config keeps failing, and restore the input schema in the serializer-parity tests.
---
 .../juneau/commons/utils/JsonValueSafety.java      |  9 +++++
 .../rest/server/mcp/McpSerializerParity_Test.java  | 13 +++----
 .../rest/server/mcp/v20250618/McpRevision.java     | 16 ++++++---
 .../server/mcp/v20250618/McpSchemaCheck_Test.java  | 41 ++++++++++++++++++++++
 .../v20250618/McpStructuredOutputSafety_Test.java  | 33 +++++++++++++++++
 .../rest/server/mcp/v20260728/McpRevision.java     |  5 +--
 .../rest/server/mcp/v20260728/McpSchemaSafety.java | 15 ++++----
 .../server/mcp/v20260728/McpSchemaSafety_Test.java | 38 ++++++++++++++++++++
 .../mcp/v20260728/McpSerializerParity_Test.java    | 13 +++----
 .../v20260728/McpStructuredOutputSafety_Test.java  | 33 +++++++++++++++++
 .../juneau/rest/server/mcp/McpTypedHandlers.java   |  2 +-
 11 files changed, 184 insertions(+), 34 deletions(-)

diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/JsonValueSafety.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/JsonValueSafety.java
index caa01f7c18..378508ae1a 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/JsonValueSafety.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/utils/JsonValueSafety.java
@@ -46,6 +46,15 @@ public final class JsonValueSafety {
 	/** Maximum allowed wall-clock time, in milliseconds, for a single traversal. */
 	public static final long MAX_TRAVERSAL_MILLIS = 100;
 
+	/**
+	 * Deadline value meaning "no wall-clock limit": the depth, {@link #MAX_NODES node-count} and cycle bounds still apply.
+	 *
+	 * <p>
+	 * Use this for server-authored or already size-bounded values, where a slow walk (a loaded host, a GC pause)
+	 * must not be reported as a violation.
+	 */
+	public static final long NO_DEADLINE = Long.MAX_VALUE;
+
 	private JsonValueSafety() {}
 
 	/**
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/McpSerializerParity_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/McpSerializerParity_Test.java
index 086e0d3bc9..c657c71559 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/McpSerializerParity_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/McpSerializerParity_Test.java
@@ -80,17 +80,11 @@ class McpSerializerParity_Test extends TestBase {
 	}
 
 	/**
-	 * Typed tool adapted then re-exposed <em>without</em> an input schema.
+	 * Typed tool adapted then re-exposed with its derived schemas.
 	 *
 	 * <p>
-	 * {@link Result} still drives output-schema {@code $defs}/{@code $ref} and the structured-content
-	 * URI/fragment fields under test. Input schema is deliberately omitted: {@code tools/call} runs
-	 * {@code McpSchemaSafety.validateInput}, which shares a 100&nbsp;ms wall-clock
-	 * {@link org.apache.juneau.commons.utils.JsonValueSafety} deadline across the schema and argument walks.
-	 * Under CI load ({@code forkCount=8}) one of the two sequential servlet/mixin calls can trip
-	 * {@code Tool input schema traversal exceeded 100 ms} while the other succeeds, breaking byte-identity.
-	 * A null input schema makes {@code validateInput} a no-op so both sides stay on the success path;
-	 * serializer policy under test does not depend on input-schema validation.
+	 * {@link Result} drives output-schema {@code $defs}/{@code $ref} and the structured-content
+	 * URI/fragment fields under test.
 	 *
 	 * <p>
 	 * Argument type remains {@code Map.class} (not a bean {@code Args}) so the typed adapter's schema
@@ -108,6 +102,7 @@ class McpSerializerParity_Test extends TestBase {
 			@Override public McpToolSpec descriptor() {
 				return new McpToolSpec()
 					.setName(adapted.descriptor().getName())
+					.setInputSchema(adapted.descriptor().getInputSchema())
 					.setOutputSchema(adapted.descriptor().getOutputSchema());
 			}
 			@Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
diff --git a/juneau-rest/juneau-rest-server-mcp-v20250618/src/main/java/org/apache/juneau/rest/server/mcp/v20250618/McpRevision.java b/juneau-rest/juneau-rest-server-mcp-v20250618/src/main/java/org/apache/juneau/rest/server/mcp/v20250618/McpRevision.java
index c431bc9184..5fc5349c16 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20250618/src/main/java/org/apache/juneau/rest/server/mcp/v20250618/McpRevision.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20250618/src/main/java/org/apache/juneau/rest/server/mcp/v20250618/McpRevision.java
@@ -128,8 +128,10 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
 	 *
 	 * <p>
 	 * Before walking, each schema is passed through {@link JsonValueSafety} to bound nesting depth,
-	 * node count, traversal time, and cyclic/shared references, so a pathological config cannot stall
-	 * or exhaust the walk.
+	 * node count, and cyclic/shared references, so a pathological config cannot stall or exhaust the
+	 * walk. The wall-clock deadline is not applied: the schemas are server-authored and the node bound
+	 * already limits the work, so a slow walk (a loaded CI host, a GC pause) must not reject a valid
+	 * config.
 	 *
 	 * <p>
 	 * Note this cannot be a complete guarantee against every possible wire shape: {@code JsonSchema}
@@ -155,7 +157,8 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
 		if (schema == null)
 			return;
 		var raw = schema.toJsonMap();
-		JsonValueSafety.check(raw, "Tool '" + toolName + "' " + role + " for MCP revision 2025-06-18");
+		// No deadline: a server-authored schema must not fail validation because the host is slow.
+		JsonValueSafety.check(raw, "Tool '" + toolName + "' " + role + " for MCP revision 2025-06-18", JsonValueSafety.NO_DEADLINE);
 		if (neq(raw.get("type"), "object"))
 			throw iaex("Tool ''%s'' %s root type ''%s'' is not object; MCP revision 2025-06-18 requires object schemas.",
 				toolName, role, raw.get("type"));
@@ -231,8 +234,10 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
 		reqnn("config", config);
 		reqnn("ctx", ctx);
 
-		if (VALIDATED.add(config))
+		if (! VALIDATED.contains(config)) {
 			validateSchemas(config);
+			VALIDATED.add(config); // Only after success, so a bad config keeps failing on every request.
+		}
 
 		var req = exchange.request();
 		if (req == null)
@@ -338,7 +343,8 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
 			throw new McpException(CODE_INTERNAL_ERROR,
 				"Tool structuredContent must be a JSON object for MCP revision 2025-06-18");
 		try {
-			JsonValueSafety.check(outcome.getStructuredContent(), "Tool structuredContent");
+			// No deadline: the node bound caps the walk, and a slow host must not fail a valid result.
+			JsonValueSafety.check(outcome.getStructuredContent(), "Tool structuredContent", JsonValueSafety.NO_DEADLINE);
 		} catch (IllegalArgumentException e) {
 			throw new McpException(CODE_INTERNAL_ERROR, e.getMessage());
 		}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20250618/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpSchemaCheck_Test.java b/juneau-rest/juneau-rest-server-mcp-v20250618/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpSchemaCheck_Test.java
index e49173ae46..e869852be7 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20250618/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpSchemaCheck_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20250618/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpSchemaCheck_Test.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.*;
 import java.util.*;
 
 import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.utils.*;
 import org.apache.juneau.marshall.collections.*;
 import org.apache.juneau.rest.server.mcp.*;
 import org.junit.jupiter.api.*;
@@ -295,4 +296,44 @@ class McpSchemaCheck_Test {
 		var config = new McpServerConfig().addTool(tool("t", schema, null));
 		assertThrows(IllegalArgumentException.class, () -> McpRevision.validateSchemas(config));
 	}
+
+	/** A valid schema whose first walk stalls past the {@link JsonValueSafety} deadline, like a loaded host or GC pause. */
+	@SuppressWarnings({
+		"java:S2160" // Test double: equality is irrelevant, only the stalled entrySet() matters.
+	})
+	private static final class StallingSchema extends JsonMap {
+		private static final long serialVersionUID = 1L;
+		private boolean stalled;
+
+		@Override
+		public Set<Map.Entry<String,Object>> entrySet() {
+			if (! stalled) {
+				stalled = true;
+				try {
+					Thread.sleep(JsonValueSafety.MAX_TRAVERSAL_MILLIS + 50);
+				} catch (InterruptedException e) {
+					Thread.currentThread().interrupt();
+				}
+			}
+			return super.entrySet();
+		}
+	}
+
+	@Test
+	void e04_validateSchemas_slowWalk_notRejected() {
+		var schema = new StallingSchema();
+		schema.put("type", "object");
+		var config = new McpServerConfig().addTool(tool("t", null, schema));
+		assertDoesNotThrow(() -> McpRevision.validateSchemas(config));
+	}
+
+	@Test
+	void e05_badConfig_failsOnEveryRequest() {
+		var config = new McpServerConfig().addTool(tool("risky", JsonMap.of("type", "object", "oneOf", List.of()), null));
+		var revision = new McpRevision(null);
+		var exchange = ping();
+		assertThrows(IllegalArgumentException.class, () -> revision.dispatch(exchange, config, ctx));
+		// A config is marked validated only after its schemas pass, so the second request still fails.
+		assertThrows(IllegalArgumentException.class, () -> revision.dispatch(exchange, config, ctx));
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-mcp-v20250618/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpStructuredOutputSafety_Test.java b/juneau-rest/juneau-rest-server-mcp-v20250618/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpStructuredOutputSafety_Test.java
index 616a7aae4e..953d8563e1 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20250618/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpStructuredOutputSafety_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20250618/src/test/java/org/apache/juneau/rest/server/mcp/v20250618/McpStructuredOutputSafety_Test.java
@@ -24,6 +24,7 @@ import java.util.*;
 import org.apache.juneau.bean.jsonrpc.*;
 import org.apache.juneau.bean.mcp.v20250618.*;
 import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.utils.*;
 import org.apache.juneau.marshall.collections.*;
 import org.apache.juneau.rest.server.mcp.McpExchange;
 import org.apache.juneau.rest.server.mcp.McpResponseResult;
@@ -172,4 +173,36 @@ class McpStructuredOutputSafety_Test {
 			assertInstanceOf(CallToolResult.class, resp.getResult());
 		});
 	}
+
+	/** A valid map whose first walk stalls past the {@link JsonValueSafety} deadline, like a loaded host or GC pause. */
+	@SuppressWarnings({
+		"java:S2160", // Test double: equality is irrelevant, only the stalled entrySet() matters.
+		"java:S2925"  // The sleep simulates a stalled host, not a wait for a condition.
+	})
+	private static final class StallingMap extends LinkedHashMap<String,Object> {
+		private static final long serialVersionUID = 1L;
+		private boolean stalled;
+
+		@Override
+		public Set<Map.Entry<String,Object>> entrySet() {
+			if (! stalled) {
+				stalled = true;
+				try {
+					Thread.sleep(JsonValueSafety.MAX_TRAVERSAL_MILLIS + 50);
+				} catch (InterruptedException e) {
+					Thread.currentThread().interrupt();
+				}
+			}
+			return super.entrySet();
+		}
+	}
+
+	@Test
+	void b03_slowWalk_notRejected() {
+		var slow = new StallingMap();
+		slow.put("a", 1);
+		var resp = send(configReturning(slow), requestWithId(1));
+		assertNull(resp.getError());
+		assertInstanceOf(CallToolResult.class, resp.getResult());
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
index 2dc5d01a77..65eb2b7989 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
@@ -694,7 +694,8 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
 		if (outcome == null || outcome.getStructuredContent() == null)
 			return;
 		try {
-			JsonValueSafety.check(outcome.getStructuredContent(), "Tool structuredContent");
+			// No deadline: the node bound caps the walk, and a slow host must not fail a valid result.
+			JsonValueSafety.check(outcome.getStructuredContent(), "Tool structuredContent", JsonValueSafety.NO_DEADLINE);
 		} catch (IllegalArgumentException e) {
 			throw new McpException(CODE_INTERNAL_ERROR, e.getMessage());
 		}
@@ -1071,7 +1072,7 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
 	 */
 	static String argumentsHash(Map<String,Object> arguments) {
 		try {
-			JsonValueSafety.check(arguments, PARAM_ARGUMENTS);
+			JsonValueSafety.check(arguments, PARAM_ARGUMENTS, JsonValueSafety.NO_DEADLINE); // Node-bounded; no wall clock.
 		} catch (IllegalArgumentException e) {
 			throw new McpException(CODE_INVALID_PARAMS, e.getMessage());
 		}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
index 32b0086917..0b961cd2a7 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety.java
@@ -35,10 +35,10 @@ import org.apache.juneau.rest.server.mcp.McpSchema;
  * <p>
  * The neutral {@link McpSchema} is an unconstrained JSON object carrier, and a client can send an
  * arbitrarily large or deep argument object. Before any validation runs, both the schema graph and the
- * argument graph are checked against the shared {@link JsonValueSafety} depth/node/deadline bounds,
+ * argument graph are checked against the shared {@link JsonValueSafety} depth/node bounds
+ * (no wall-clock deadline: the node bound caps the work, and a slow host must not reject valid input),
  * so a hostile or accidentally-huge input is rejected up front rather than blowing the stack or spinning
- * indefinitely. The two checks share a single deadline, so a slow schema graph cannot buy the argument
- * graph extra time (or vice versa).
+ * indefinitely.
  *
  * <p>
  * <b>No external fetches.</b> The schema is converted to a {@link JsonSchema} bean and validated as-is. No
@@ -172,10 +172,10 @@ final class McpSchemaSafety {
 		if (schema == null)
 			return;
 		var schemaMap = schema.toJsonMap();
-		var deadline = JsonValueSafety.deadlineNanos();
 		try {
-			JsonValueSafety.check(schemaMap, "Tool input schema", deadline);
-			JsonValueSafety.check(args, "Tool input", deadline);
+			// No wall clock here: both walks are node-bounded, and the validation step below has its own compute budget.
+			JsonValueSafety.check(schemaMap, "Tool input schema", JsonValueSafety.NO_DEADLINE);
+			JsonValueSafety.check(args, "Tool input", JsonValueSafety.NO_DEADLINE);
 		} catch (IllegalArgumentException e) {
 			throw new McpException(McpRevision.CODE_INVALID_PARAMS, e.getMessage());
 		}
@@ -186,8 +186,7 @@ final class McpSchemaSafety {
 	/**
 	 * Runs {@link JsonSchemaValidator} on a daemon thread and enforces a fresh {@link #MAX_VALIDATION_MILLIS}
 	 * compute budget against the task's own compute time - deliberately <b>not</b> the caller's remaining share
-	 * of the structural-traversal deadline computed in {@link #validateInput}. That deadline is a wall-clock
-	 * budget that is already partially spent by the two {@link JsonValueSafety#check} calls and the
+	 * of any structural-traversal wall clock. Such a budget would already be partially spent by the two {@link JsonValueSafety#check} calls and the
 	 * schema-to-bean conversion above, both of which run on the (possibly preempted) request thread under load;
 	 * deriving the validation budget from whatever wall-clock happened to remain would let purely-external
 	 * scheduling pressure - not any property of the schema or arguments being validated - trip a false-positive
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
index cafa15e3df..960e88b328 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSchemaSafety_Test.java
@@ -497,4 +497,42 @@ class McpSchemaSafety_Test {
 			return null;
 		}
 	}
+
+	/** A valid map whose first walk stalls past the {@link JsonValueSafety} deadline, like a loaded host or GC pause. */
+	@SuppressWarnings({
+		"java:S2160", // Test double: equality is irrelevant, only the stalled entrySet() matters.
+		"java:S2925"  // The sleep simulates a stalled host, not a wait for a condition.
+	})
+	private static final class StallingMap extends LinkedHashMap<String,Object> {
+		private static final long serialVersionUID = 1L;
+		private boolean stalled;
+
+		@Override
+		public Set<Map.Entry<String,Object>> entrySet() {
+			if (! stalled) {
+				stalled = true;
+				try {
+					Thread.sleep(JsonValueSafety.MAX_TRAVERSAL_MILLIS + 50);
+				} catch (InterruptedException e) {
+					Thread.currentThread().interrupt();
+				}
+			}
+			return super.entrySet();
+		}
+	}
+
+	@Test
+	void d09_validateInput_slowArgumentWalk_notRejected() {
+		var schema = McpSchema.of(JsonMap.of("type", "object"));
+		var args = new StallingMap();
+		args.put("a", 1);
+		assertDoesNotThrow(() -> McpSchemaSafety.validateInput(schema, args));
+	}
+
+	@Test
+	void d10_argumentsHash_slowWalk_notRejected() {
+		var args = new StallingMap();
+		args.put("a", 1);
+		assertDoesNotThrow(() -> McpRevision.argumentsHash(args));
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSerializerParity_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSerializerParity_Test.java
index 17b20eb5a7..a8a2cb6fc9 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSerializerParity_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSerializerParity_Test.java
@@ -84,17 +84,11 @@ class McpSerializerParity_Test extends TestBase {
 	}
 
 	/**
-	 * Typed tool adapted then re-exposed <em>without</em> an input schema.
+	 * Typed tool adapted then re-exposed with its derived schemas.
 	 *
 	 * <p>
-	 * {@link Result} still drives output-schema {@code $defs}/{@code $ref} and the structured-content
-	 * URI/fragment fields under test. Input schema is deliberately omitted: {@code tools/call} runs
-	 * {@code McpSchemaSafety.validateInput}, which shares a 100&nbsp;ms wall-clock
-	 * {@link org.apache.juneau.commons.utils.JsonValueSafety} deadline across the schema and argument walks.
-	 * Under CI load ({@code forkCount=8}) one of the two sequential servlet/mixin calls can trip
-	 * {@code Tool input schema traversal exceeded 100 ms} while the other succeeds, breaking byte-identity.
-	 * A null input schema makes {@code validateInput} a no-op so both sides stay on the success path;
-	 * serializer policy under test does not depend on input-schema validation.
+	 * {@link Result} drives output-schema {@code $defs}/{@code $ref} and the structured-content
+	 * URI/fragment fields under test.
 	 *
 	 * <p>
 	 * Argument type remains {@code Map.class} (not a bean {@code Args}) so the typed adapter's schema
@@ -112,6 +106,7 @@ class McpSerializerParity_Test extends TestBase {
 			@Override public McpToolSpec descriptor() {
 				return new McpToolSpec()
 					.setName(adapted.descriptor().getName())
+					.setInputSchema(adapted.descriptor().getInputSchema())
 					.setOutputSchema(adapted.descriptor().getOutputSchema());
 			}
 			@Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpStructuredOutputSafety_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpStructuredOutputSafety_Test.java
index cea58b5999..330bbd65cd 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpStructuredOutputSafety_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpStructuredOutputSafety_Test.java
@@ -24,6 +24,7 @@ import java.util.*;
 import org.apache.juneau.bean.jsonrpc.*;
 import org.apache.juneau.bean.mcp.v20260728.*;
 import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.utils.*;
 import org.apache.juneau.marshall.collections.*;
 import org.apache.juneau.rest.server.mcp.McpExchange;
 import org.apache.juneau.rest.server.mcp.McpResponseResult;
@@ -199,4 +200,36 @@ class McpStructuredOutputSafety_Test {
 		assertNull(resp.getError());
 		assertInstanceOf(CallToolResult.class, resp.getResult());
 	}
+
+	/** A valid map whose first walk stalls past the {@link JsonValueSafety} deadline, like a loaded host or GC pause. */
+	@SuppressWarnings({
+		"java:S2160", // Test double: equality is irrelevant, only the stalled entrySet() matters.
+		"java:S2925"  // The sleep simulates a stalled host, not a wait for a condition.
+	})
+	private static final class StallingMap extends LinkedHashMap<String,Object> {
+		private static final long serialVersionUID = 1L;
+		private boolean stalled;
+
+		@Override
+		public Set<Map.Entry<String,Object>> entrySet() {
+			if (! stalled) {
+				stalled = true;
+				try {
+					Thread.sleep(JsonValueSafety.MAX_TRAVERSAL_MILLIS + 50);
+				} catch (InterruptedException e) {
+					Thread.currentThread().interrupt();
+				}
+			}
+			return super.entrySet();
+		}
+	}
+
+	@Test
+	void b07_slowWalk_notRejected() {
+		var slow = new StallingMap();
+		slow.put("a", 1);
+		var resp = send(configReturning(slow), requestWithId(1));
+		assertNull(resp.getError());
+		assertInstanceOf(CallToolResult.class, resp.getResult());
+	}
 }
diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpTypedHandlers.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpTypedHandlers.java
index 4ad947a62c..eaf4c411fa 100644
--- a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpTypedHandlers.java
+++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpTypedHandlers.java
@@ -84,7 +84,7 @@ public final class McpTypedHandlers {
 				var result = typed.call(bound, ctx);
 				var tree = canonicalize(result);
 				try {
-					JsonValueSafety.check(tree, "Tool structuredContent");
+					JsonValueSafety.check(tree, "Tool structuredContent", JsonValueSafety.NO_DEADLINE); // Node-bounded; no wall clock.
 				} catch (IllegalArgumentException e) {
 					throw new McpException(-32603, e.getMessage());
 				}