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 995dc12937 MCP 2026-07-28 MRTR wire fixtures + end-to-end client
integration (TODO-318 phases 4-6)
995dc12937 is described below
commit 995dc129379d8e82384f3fb0b966c11858922e89
Author: James Bognar <[email protected]>
AuthorDate: Mon Aug 3 09:45:16 2026 -0700
MCP 2026-07-28 MRTR wire fixtures + end-to-end client integration (TODO-318
phases 4-6)
Completes the MRTR / input_required work for the 2026-07-28 revision.
- Characterization wire fixtures pinning the input_required response, the
resume request (inputResponses + echoed requestState), and the tamper /
expired / max-rounds / unsupported-capability error shapes; mirrored in
the
integration-tests fixture tree, with a deterministic fixed-key GCM codec
and
requestState redaction so AEAD non-determinism doesn't break
byte-comparison.
- End-to-end pause->resume->complete loop exercised through the real
McpClient
against a live embedded-Jetty v2 server, plus capability-gate and tamper
negative paths.
- McpClient.callRaw(method, params): additive, result-type-agnostic dispatch
that bypasses the typed response cache, letting a client receive
input_required and re-issue with requestState/inputResponses.
- Fixes a residual BasicBeanStore resource leak in McpRevision by deferring
store construction until requestState validation succeeds, and clears lint
(unused import; resolved TODO and empty default-case in McpClient).
v2-only; neutral core and the 2025-06-18 revision are unchanged.
Co-authored-by: Cursor <[email protected]>
---
.../mcp/v20260728/McpMrtrIntegration_Test.java | 201 +++++++++++++++++++++
.../mcp/v20260728/Characterization_Test.java | 169 ++++++++++++++++-
.../MRTR-expired-request-state.headers.properties | 17 ++
.../MRTR-expired-request-state.request.json | 1 +
.../MRTR-expired-request-state.response.json | 1 +
...MRTR-input-required-response.headers.properties | 17 ++
.../MRTR-input-required-response.request.json | 1 +
.../MRTR-input-required-response.response.json | 1 +
.../MRTR-max-rounds-exceeded.headers.properties | 17 ++
.../MRTR-max-rounds-exceeded.request.json | 1 +
.../MRTR-max-rounds-exceeded.response.json | 1 +
.../MRTR-resume-complete.headers.properties | 17 ++
.../MRTR-resume-complete.request.json | 1 +
.../MRTR-resume-complete.response.json | 1 +
...-resume-input-required-again.headers.properties | 17 ++
.../MRTR-resume-input-required-again.request.json | 1 +
.../MRTR-resume-input-required-again.response.json | 1 +
.../MRTR-tampered-request-state.headers.properties | 17 ++
.../MRTR-tampered-request-state.request.json | 1 +
.../MRTR-tampered-request-state.response.json | 1 +
.../MRTR-unsupported-capability.headers.properties | 17 ++
.../MRTR-unsupported-capability.request.json | 1 +
.../MRTR-unsupported-capability.response.json | 1 +
.../rest/client/mcp/v20260728/McpClient.java | 59 +++++-
.../mcp/v20260728/McpClient_CallRaw_Test.java | 131 ++++++++++++++
.../rest/server/mcp/v20260728/McpRevision.java | 16 +-
.../mcp/v20260728/Characterization_Test.java | 169 ++++++++++++++++-
.../MRTR-expired-request-state.headers.properties | 17 ++
.../MRTR-expired-request-state.request.json | 1 +
.../MRTR-expired-request-state.response.json | 1 +
...MRTR-input-required-response.headers.properties | 17 ++
.../MRTR-input-required-response.request.json | 1 +
.../MRTR-input-required-response.response.json | 1 +
.../MRTR-max-rounds-exceeded.headers.properties | 17 ++
.../MRTR-max-rounds-exceeded.request.json | 1 +
.../MRTR-max-rounds-exceeded.response.json | 1 +
.../MRTR-resume-complete.headers.properties | 17 ++
.../MRTR-resume-complete.request.json | 1 +
.../MRTR-resume-complete.response.json | 1 +
...-resume-input-required-again.headers.properties | 17 ++
.../MRTR-resume-input-required-again.request.json | 1 +
.../MRTR-resume-input-required-again.response.json | 1 +
.../MRTR-tampered-request-state.headers.properties | 17 ++
.../MRTR-tampered-request-state.request.json | 1 +
.../MRTR-tampered-request-state.response.json | 1 +
.../MRTR-unsupported-capability.headers.properties | 17 ++
.../MRTR-unsupported-capability.request.json | 1 +
.../MRTR-unsupported-capability.response.json | 1 +
48 files changed, 997 insertions(+), 14 deletions(-)
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
new file mode 100644
index 0000000000..8192e2d0d9
--- /dev/null
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
@@ -0,0 +1,201 @@
+/*
+ * 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.client.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.bean.jsonrpc.*;
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.mcp.*;
+import org.apache.juneau.rest.server.mcp.v20260728.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.*;
+
+import jakarta.servlet.*;
+
+/**
+ * End-to-end coverage for the {@code 2026-07-28} Multi-Round-Trip Request
(SEP-2322 {@code input_required}) loop,
+ * driven through the first-party {@link McpClient} against a live
embedded-Jetty v2
+ * {@link org.apache.juneau.rest.server.mcp.v20260728.McpRestServlet} fixture
— proving PAUSE→RESUME→
+ * complete works across a real client/server wire boundary, not just the
in-process dispatcher assertions the
+ * module-local {@code McpMrtrDispatch_Test} already covers.
+ *
+ * <p>
+ * Placed in the {@code rest.client.mcp.v20260728} package (beside {@link
McpClientV20260728_Integration_Test},
+ * the canonical real-client/real-server fixture pattern) rather than the
server package, because Task 15 mandates
+ * driving the loop through the real client stack; the server-package
integration tests use raw {@code MockRestClient}
+ * bodies instead.
+ *
+ * <p>
+ * <b>Client resume path:</b> both the pausing and the resuming calls go
through {@link McpClient#callRaw} —
+ * the typed {@code callTool} overload can neither represent an {@code
input_required} result nor set
+ * {@code requestState}/{@code inputResponses} on the follow-up request.
Ergonomic client-side MRTR-resume support
+ * (auto-detect + re-issue) is intentionally out of scope and remains a
separate follow-up; here the test drives
+ * the loop explicitly, exactly as the plan's Task 15 scopes it.
+ */
[email protected]
+class McpMrtrIntegration_Test extends TestBase {
+
+ //
-----------------------------------------------------------------------------------------------------------------
+ // Fixture server: a v2 MCP servlet whose tools pause via
McpInputRequiredSignal and resume via the injected
+ // McpMrtrResumeContext. Holds the exact AeadRequestStateCodec instance
the dispatcher seals with, so a02 can
+ // unseal the emitted requestState without re-deriving a key (the
ephemeral AES key lives inside this instance).
+ //
-----------------------------------------------------------------------------------------------------------------
+
+ static final AeadRequestStateCodec CODEC = new AeadRequestStateCodec();
+
+ private static String aad(String method) {
+ return method + '\u0000' + McpProtocol.VERSION_2026_07_28;
+ }
+
+ private static McpToolHandler ask() {
+ return new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new
McpToolSpec().setName("ask").setDescription("Pauses once, then completes"); }
+ @Override public McpToolOutcome call(Map<String,Object>
arguments, BeanStore ctx) {
+ var resume =
ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty())
+ throw new
McpInputRequiredSignal(Map.of("q1", Map.of("type", "elicitation")), "cont-1");
+ return McpToolOutcome.text("resumed:" +
resume.get().inputResponses().get("q1"));
+ }
+ };
+ }
+
+ private static McpToolHandler askTwice() {
+ return new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new
McpToolSpec().setName("askTwice").setDescription("Pauses twice"); }
+ @Override public McpToolOutcome call(Map<String,Object>
arguments, BeanStore ctx) {
+ var resume =
ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty())
+ throw new
McpInputRequiredSignal(Map.of("q1", Map.of("type", "elicitation")), "cont-1");
+ if
("cont-1".equals(resume.get().continuation()))
+ throw new
McpInputRequiredSignal(Map.of("q2", Map.of("type", "elicitation")), "cont-2");
+ return McpToolOutcome.text("resumed2:" +
resume.get().inputResponses().get("q2"));
+ }
+ };
+ }
+
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class,
defaultAccept = "application/json")
+ public static class Fixture extends
org.apache.juneau.rest.server.mcp.v20260728.McpRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected McpServerConfig createMcpConfig() {
+ return new
McpServerConfig().setName("it-mrtr-fixture").setVersion("1.0.0").addTool(ask()).addTool(askTwice());
+ }
+
+ @Override
+ protected McpMrtrConfig createMrtrConfig() {
+ return new McpMrtrConfig().setCodec(CODEC);
+ }
+ }
+
+ @Configuration
+ public static class FixtureConfig {
+ @Bean public Servlet mcpServlet() { return new Fixture(); }
+ }
+
+ @RegisterExtension
+ static MicroserviceTestFixture fixture =
MicroserviceTestFixture.create()
+ .configurations(FixtureConfig.class);
+
+ private static McpClient.Builder clientBuilder(boolean withElicitation)
{
+ var caps = new ClientCapabilities();
+ if (withElicitation)
+ caps.setElicitation(new ElicitationCapability());
+ return McpClient.builder()
+ .endpoint(fixture.getRootUrl() + "/")
+ .clientCapabilities(caps);
+ }
+
+ //
=================================================================================================================
+ // A: full PAUSE -> RESUME -> complete loop across the real wire.
+ //
=================================================================================================================
+
+ @Test void a01_pauseResumeComplete_fullLoop() throws Exception {
+ try (var client = clientBuilder(true).build()) {
+ // First call pauses: expect an input_required result
carrying a requestState and one input request.
+ var paused = client.callRaw(McpMethods.TOOLS_CALL, new
CallToolRequest().setName("ask").setArguments(Map.of()));
+ assertEquals("input_required",
paused.get("resultType"));
+ var token = (String) paused.get("requestState");
+ assertNotNull(token);
+ assertTrue(((Map<?,?>)
paused.get("inputRequests")).containsKey("q1"));
+
+ // Resume with the echoed requestState + the client's
answers: expect completion echoing the answer.
+ var completed = client.callRaw(McpMethods.TOOLS_CALL,
+ new
CallToolRequest().setName("ask").setRequestState(token).setInputResponses(Map.of("q1",
"answer")));
+ assertEquals("complete", completed.get("resultType"));
+ var result = Json.to(Json.of(completed),
CallToolResult.class);
+ assertEquals("resumed:answer", ((TextContent)
result.getContent().get(0)).getText());
+ }
+ }
+
+ @Test void a02_pauseResumeToPauseAgain_secondRoundIncrementsCount()
throws Exception {
+ try (var client = clientBuilder(true).build()) {
+ var paused1 = client.callRaw(McpMethods.TOOLS_CALL, new
CallToolRequest().setName("askTwice").setArguments(Map.of()));
+ assertEquals("input_required",
paused1.get("resultType"));
+ var token1 = (String) paused1.get("requestState");
+
+ // Resume once; the handler pauses again -> a second
input_required with an incremented round counter.
+ var paused2 = client.callRaw(McpMethods.TOOLS_CALL,
+ new
CallToolRequest().setName("askTwice").setRequestState(token1).setInputResponses(Map.of("q1",
"a1")));
+ assertEquals("input_required",
paused2.get("resultType"));
+ var token2 = (String) paused2.get("requestState");
+
+ // Unseal via the same codec instance the server used
(round counter lives inside the sealed token).
+ var state = CODEC.unseal(token2,
aad(McpMethods.TOOLS_CALL)).orElseThrow();
+ assertEquals(2, state.round());
+ assertEquals("cont-2", state.continuation());
+ }
+ }
+
+ //
=================================================================================================================
+ // B: capability gate + negative path, enforced at the real HTTP
boundary.
+ //
=================================================================================================================
+
+ @Test void b01_unsupportedCapability_rejectedOverTheWire() throws
Exception {
+ try (var client = clientBuilder(false).build()) {
+ var e = assertThrows(McpException.class,
+ () -> client.callRaw(McpMethods.TOOLS_CALL, new
CallToolRequest().setName("ask").setArguments(Map.of())));
+
assertEquals(org.apache.juneau.rest.server.mcp.v20260728.McpRevision.CODE_MISSING_REQUIRED_CLIENT_CAPABILITY,
e.getCode());
+ }
+ }
+
+ @Test void b02_tamperedRequestState_rejectedOverTheWire() throws
Exception {
+ try (var client = clientBuilder(true).build()) {
+ var paused = client.callRaw(McpMethods.TOOLS_CALL, new
CallToolRequest().setName("ask").setArguments(Map.of()));
+ var token = (String) paused.get("requestState");
+ // Flip one ciphertext byte to corrupt the AEAD tag; a
final-character flip can land on an unpadded
+ // base64url "don't-care" low bit and decode to
identical bytes, leaving the tag (and test) flaky.
+ var parts = token.split("\\.", 2);
+ var ciphertext =
Base64.getUrlDecoder().decode(parts[1]);
+ ciphertext[0] ^= 1;
+ var tampered = parts[0] + "." +
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext);
+ var e = assertThrows(McpException.class,
+ () -> client.callRaw(McpMethods.TOOLS_CALL,
+ new
CallToolRequest().setName("ask").setRequestState(tampered).setInputResponses(Map.of("q1",
"answer"))));
+
assertEquals(org.apache.juneau.rest.server.mcp.v20260728.McpRevision.CODE_INVALID_PARAMS,
e.getCode());
+ }
+ }
+}
diff --git
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
index e2a3b4b9d2..77926b59de 100644
---
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
+++
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
@@ -19,19 +19,25 @@ package org.apache.juneau.rest.server.mcp.v20260728;
import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
+import java.nio.charset.*;
import java.nio.file.*;
import java.util.*;
import java.util.function.*;
+import javax.crypto.*;
+import javax.crypto.spec.*;
+
import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.commons.inject.*;
import org.apache.juneau.http.tracing.TraceContextCarrier;
import org.apache.juneau.marshall.collections.*;
import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.marshaller.Json;
import org.apache.juneau.rest.mock.classic.*;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.mcp.*;
import org.apache.juneau.rest.server.tracing.*;
+import org.junit.jupiter.api.*;
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;
@@ -271,6 +277,84 @@ class Characterization_Test {
}
}
+ /**
+ * Backs every {@code MRTR-*} fixture (SEP-2322 Multi-Round-Trip
Requests): one {@code ask} tool that pauses
+ * (throws {@link McpInputRequiredSignal}) on a first-round call, and
on RESUME either completes (echoing the
+ * client's {@code inputResponses}) or pauses a second time —
branched on the decoded continuation so a
+ * single handler drives the complete / pause-again / max-rounds
fixtures.
+ *
+ * <p>
+ * Wires a <b>deterministic, fixed-key</b> {@link FixedKeyGcmCodec}
instead of the production ephemeral-key
+ * {@link AeadRequestStateCodec}, so a {@code requestState} the harness
seals for a RESUME fixture (see
+ * {@link #mrtrToken(String)}) is unsealable by this servlet across a
fresh process — a property the
+ * random-per-process production key deliberately does <b>not</b> have.
The opaque token minted <em>into</em> a
+ * PAUSE response still embeds a wall-clock expiry, so responses are
compared with the token value redacted (see
+ * {@link #normalizeRequestState(String)}); the fixtures pin the wire
<em>shape</em>, never the opaque bytes.
+ */
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class,
defaultAccept = "application/json")
+ public static class F_Mrtr extends McpRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Override protected McpServerConfig createMcpConfig() {
+ return new McpServerConfig().addTool(new
McpToolHandler() {
+ @Override public McpToolSpec descriptor() {
return new McpToolSpec().setName("ask").setDescription("desc:ask"); }
+ @Override public McpToolOutcome
call(Map<String,Object> arguments, BeanStore ctx) {
+ var resume =
ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty())
+ throw new
McpInputRequiredSignal(Map.of("q1", Map.of("type", "elicitation")), "cont-1");
+ if
("pause-again".equals(resume.get().continuation()))
+ throw new
McpInputRequiredSignal(Map.of("q2", Map.of("type", "elicitation")), "cont-2");
+ return McpToolOutcome.text("resumed:" +
resume.get().inputResponses().get("q1"));
+ }
+ });
+ }
+ @Override protected McpMrtrConfig createMrtrConfig() {
+ return new McpMrtrConfig().setCodec(new
FixedKeyGcmCodec());
+ }
+ }
+
+ /**
+ * Test-only deterministic {@link RequestStateCodec}: AES-256-GCM with
a <b>hardcoded</b> key and nonce.
+ *
+ * <p>
+ * Unlike the production {@link AeadRequestStateCodec} (random
per-process key + random per-seal nonce), this
+ * codec produces identical ciphertext for identical {@code (state,
aad)} inputs and can unseal a token sealed by
+ * any other instance — exactly the reproducibility a committed
characterization fixture needs. It is still
+ * genuinely tamper-evident (the GCM auth tag rejects the {@code
MRTR-tampered-request-state} corruption) and
+ * honors the AAD binding. Fixed-nonce AES-GCM is catastrophically
insecure for real traffic; it is used here
+ * <em>solely</em> because determinism, not confidentiality, is the
goal in a fixture.
+ */
+ static final class FixedKeyGcmCodec implements RequestStateCodec {
+ private static final SecretKey KEY = new SecretKeySpec(new
byte[32], "AES");
+ private static final byte[] NONCE = new byte[12];
+ private static final Base64.Encoder B64 =
Base64.getUrlEncoder().withoutPadding();
+
+ @Override public String seal(McpRequestState state, String aad)
{
+ try {
+ var cipher =
Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, KEY, new
GCMParameterSpec(128, NONCE));
+
cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
+ var ciphertext =
cipher.doFinal(Json.of(state).getBytes(StandardCharsets.UTF_8));
+ return B64.encodeToString(NONCE) + "." +
B64.encodeToString(ciphertext);
+ } catch (Exception e) {
+ throw new RuntimeException("Fixture codec seal
failed", e);
+ }
+ }
+
+ @Override public Optional<McpRequestState> unseal(String token,
String aad) {
+ try {
+ var parts = token.split("\\.", 2);
+ var nonce =
Base64.getUrlDecoder().decode(parts[0]);
+ var ciphertext =
Base64.getUrlDecoder().decode(parts[1]);
+ var cipher =
Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, KEY, new
GCMParameterSpec(128, nonce));
+
cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
+ return Optional.of(Json.to(new
String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8),
McpRequestState.class));
+ } catch (@SuppressWarnings("unused") Exception e) {
+ return Optional.empty();
+ }
+ }
+ }
+
// --- fixture handler factories
---------------------------------------------------------
private static McpToolHandler tool(String name, McpSchema schema,
Function<Map<String,Object>,McpToolOutcome> fn) {
@@ -329,10 +413,74 @@ class Characterization_Test {
"COMPLETE-empty-unknown", "COMPLETE-capped" ->
F_Complete.class;
case "TRACE-meta-parent", "TRACE-http-fallback",
"TRACE-meta-wins",
"TRACE-tracestate-baggage",
"TRACE-response-echo", "TRACE-jsonrpc-error" -> F_Traced.class;
+ case "MRTR-input-required-response",
"MRTR-resume-complete", "MRTR-resume-input-required-again",
+ "MRTR-tampered-request-state",
"MRTR-expired-request-state", "MRTR-unsupported-capability",
+ "MRTR-max-rounds-exceeded" -> F_Mrtr.class;
default -> F_Empty.class;
};
}
+ // --- MRTR requestState sealing/redaction
------------------------------------------------
+
+ /** Placeholder in an {@code MRTR-*} resume fixture's committed request
body, replaced at replay time. */
+ private static final String TOKEN_PLACEHOLDER = "__REQUEST_STATE__";
+
+ // Fixed expiry constants embedded in a sealed requestState so a RESUME
fixture's token is reproducible
+ // (never "now + ttl"): far-future for a still-valid token, a fixed
past instant for the expired fixture.
+ private static final long FAR_FUTURE_MS = 32503680000000L; // ~ year
3000
+ private static final long PAST_MS = 1000L;
+
+ private static String aad(String method) {
+ return method + '\u0000' + McpProtocol.VERSION_2026_07_28;
+ }
+
+ /**
+ * The {@code requestState} the harness seals (with {@link
FixedKeyGcmCodec}, matching {@link F_Mrtr}'s codec)
+ * for each RESUME-family fixture. Substituted into the committed
request in place of {@link #TOKEN_PLACEHOLDER}
+ * so the token stays framework-owned/opaque while the request pins the
surrounding wire shape.
+ *
+ * <p>
+ * {@code MRTR-resume-complete} and {@code
MRTR-resume-input-required-again} have byte-identical committed
+ * request files by design: the behavioral difference between the two
fixtures lives entirely inside the sealed
+ * opaque token's continuation value ({@code "complete-me"} vs {@code
"pause-again"}), not in anything visible
+ * on the wire request body.
+ *
+ * <p>
+ * The plan's original "commit the corrupted requestState as a frozen
literal" constraint for
+ * {@code MRTR-tampered-request-state} was consciously superseded:
because {@link FixedKeyGcmCodec} makes
+ * {@code seal(...)} fully deterministic (fixed key, fixed nonce),
regenerating the tampered token on every run
+ * via {@code tamper(codec.seal(...))} is equally reproducible and
avoids hand-maintaining opaque ciphertext.
+ */
+ private static String mrtrToken(String fixture) {
+ var codec = new FixedKeyGcmCodec();
+ return switch (fixture) {
+ case "MRTR-resume-complete" -> codec.seal(new
McpRequestState("complete-me", "tools/call", 1, FAR_FUTURE_MS),
aad("tools/call"));
+ case "MRTR-resume-input-required-again" ->
codec.seal(new McpRequestState("pause-again", "tools/call", 1, FAR_FUTURE_MS),
aad("tools/call"));
+ case "MRTR-expired-request-state" -> codec.seal(new
McpRequestState("cont-1", "tools/call", 1, PAST_MS), aad("tools/call"));
+ case "MRTR-max-rounds-exceeded" -> codec.seal(new
McpRequestState("cont-1", "tools/call", McpMrtrConfig.DEFAULT_MAX_ROUNDS,
FAR_FUTURE_MS), aad("tools/call"));
+ case "MRTR-tampered-request-state" ->
tamper(codec.seal(new McpRequestState("cont-1", "tools/call", 1,
FAR_FUTURE_MS), aad("tools/call")));
+ default -> throw new IllegalArgumentException("No MRTR
token mapping for fixture: " + fixture);
+ };
+ }
+
+ /** Flips one ciphertext byte of a valid token so its GCM tag fails
verification (the tamper fixture). */
+ private static String tamper(String token) {
+ var parts = token.split("\\.", 2);
+ var ciphertext = Base64.getUrlDecoder().decode(parts[1]);
+ ciphertext[0] ^= 1;
+ return parts[0] + "." +
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext);
+ }
+
+ /**
+ * Redacts the opaque {@code requestState} token value in a response
body to a fixed placeholder before
+ * comparison. The token is per-process-nondeterministic ciphertext
(production uses a random key and embeds a
+ * wall-clock expiry), so pinning its exact bytes is neither possible
nor meaningful; the fixture pins its
+ * presence and the surrounding wire shape instead.
+ */
+ private static String normalizeRequestState(String responseBody) {
+ return responseBody.replaceAll("\"requestState\":\"[^\"]+\"",
"\"requestState\":\"<sealed>\"");
+ }
+
// --- replay
----------------------------------------------------------------------------
static List<String> fixtures() throws Exception {
@@ -348,7 +496,7 @@ class Characterization_Test {
@ParameterizedTest
@MethodSource("fixtures")
void a01_wireIsUnchanged(String fixture) throws Exception {
- var actual = replayHttp(fixture);
+ var actual = normalizeRequestState(replayHttp(fixture));
var expected = DIR.resolve(fixture + ".response.json");
if (WRITE) {
Files.writeString(expected, actual);
@@ -358,8 +506,27 @@ class Characterization_Test {
() -> fixture + ": WIRE FORMAT CHANGED. Do not update
the fixture — fix the code.");
}
+ /**
+ * The opaque {@code requestState} minted into {@code
MRTR-resume-input-required-again}'s second PAUSE is
+ * redacted for {@code a01}'s byte comparison, so its embedded round
counter is verified here instead: unseal
+ * the live token with the same fixed-key codec {@link F_Mrtr} uses and
assert the round advanced to 2 (plan
+ * Phase 4 Task 12 decode-and-check).
+ */
+ @Test
+ void a02_resumeInputRequiredAgain_sealedRoundIsTwo() throws Exception {
+ var raw = replayHttp("MRTR-resume-input-required-again");
+ var envelope = Json.to(raw, JsonMap.class);
+ var result = (Map<?,?>) envelope.get("result");
+ var token = (String) result.get("requestState");
+ var state = new FixedKeyGcmCodec().unseal(token,
aad("tools/call")).orElseThrow();
+ assertEquals(2, state.round());
+ assertEquals("cont-2", state.continuation());
+ }
+
private String replayHttp(String fixture) throws Exception {
var requestBody = Files.readString(DIR.resolve(fixture +
".request.json")).strip();
+ if (requestBody.contains(TOKEN_PLACEHOLDER))
+ requestBody = requestBody.replace(TOKEN_PLACEHOLDER,
mrtrToken(fixture));
var headers = loadHeaders(fixture);
var client = MockRestClient.create(servletFor(fixture)).json()
.contentType("application/json").accept("application/json").ignoreErrors().build();
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-expired-request-state.headers.properties
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-expired-request-state.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-expired-request-state.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-expired-request-state.request.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-expired-request-state.request.json
new file mode 100644
index 0000000000..97fc1a6f99
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-expired-request-state.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-expired-request-state.response.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-expired-request-state.response.json
new file mode 100644
index 0000000000..a1727204b3
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-expired-request-state.response.json
@@ -0,0 +1 @@
+{"error":{"code":-32022,"message":"requestState has
expired"},"id":1,"jsonrpc":"2.0"}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-input-required-response.headers.properties
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-input-required-response.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-input-required-response.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-input-required-response.request.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-input-required-response.request.json
new file mode 100644
index 0000000000..0e2aa4847b
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-input-required-response.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-input-required-response.response.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-input-required-response.response.json
new file mode 100644
index 0000000000..6f7cc8908b
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-input-required-response.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"inputRequests":{"q1":{"type":"elicitation"}},"requestState":"<sealed>","resultType":"input_required"}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-max-rounds-exceeded.headers.properties
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-max-rounds-exceeded.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-max-rounds-exceeded.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-max-rounds-exceeded.request.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-max-rounds-exceeded.request.json
new file mode 100644
index 0000000000..97fc1a6f99
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-max-rounds-exceeded.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-max-rounds-exceeded.response.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-max-rounds-exceeded.response.json
new file mode 100644
index 0000000000..b3eef6825e
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-max-rounds-exceeded.response.json
@@ -0,0 +1 @@
+{"error":{"code":-32023,"message":"Max MRTR rounds
exceeded"},"id":1,"jsonrpc":"2.0"}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-complete.headers.properties
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-complete.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-complete.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-complete.request.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-complete.request.json
new file mode 100644
index 0000000000..2e2ecabcba
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-complete.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","inputResponses":{"q1":"answer"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-complete.response.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-complete.response.json
new file mode 100644
index 0000000000..763cc3f7cc
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-complete.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"content":[{"type":"text","text":"resumed:answer"}],"resultType":"complete"}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-input-required-again.headers.properties
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-input-required-again.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-input-required-again.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-input-required-again.request.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-input-required-again.request.json
new file mode 100644
index 0000000000..2e2ecabcba
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-input-required-again.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","inputResponses":{"q1":"answer"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-input-required-again.response.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-input-required-again.response.json
new file mode 100644
index 0000000000..0980582389
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-resume-input-required-again.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"inputRequests":{"q2":{"type":"elicitation"}},"requestState":"<sealed>","resultType":"input_required"}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-tampered-request-state.headers.properties
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-tampered-request-state.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-tampered-request-state.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-tampered-request-state.request.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-tampered-request-state.request.json
new file mode 100644
index 0000000000..97fc1a6f99
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-tampered-request-state.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-tampered-request-state.response.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-tampered-request-state.response.json
new file mode 100644
index 0000000000..1157a8dfb7
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-tampered-request-state.response.json
@@ -0,0 +1 @@
+{"error":{"code":-32602,"message":"Invalid or tampered
requestState"},"id":1,"jsonrpc":"2.0"}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-unsupported-capability.headers.properties
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-unsupported-capability.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-unsupported-capability.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-unsupported-capability.request.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-unsupported-capability.request.json
new file mode 100644
index 0000000000..1c5c5da477
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-unsupported-capability.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
\ No newline at end of file
diff --git
a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-unsupported-capability.response.json
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-unsupported-capability.response.json
new file mode 100644
index 0000000000..56242140a5
--- /dev/null
+++
b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/MRTR-unsupported-capability.response.json
@@ -0,0 +1 @@
+{"error":{"code":-32021,"message":"Client does not advertise the elicitation
capability required for input_required"},"id":1,"jsonrpc":"2.0"}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java
index bd750f9547..5c79a3619c 100644
---
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java
+++
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java
@@ -222,6 +222,34 @@ public final class McpClient extends AbstractMcpClient {
return call(McpMethods.COMPLETION_COMPLETE, params,
CompleteResult.class);
}
+ /**
+ * Performs a JSON-RPC call using a caller-supplied, fully-populated
request bean, returning the raw result
+ * payload without deserializing into a specific {@link Result} subtype.
+ *
+ * <p>
+ * Every convenience method on this class ({@link #callTool}, {@link
#getPrompt}, {@link #readResource}, etc.)
+ * both builds its own request bean internally and deserializes
strictly into one known result type, so none of
+ * them can represent a paused {@code input_required} response (MCP
{@code 2026-07-28} SEP-2322 Multi-Round-Trip
+ * Requests) or carry the {@code requestState}/{@code inputResponses}
fields a resume call needs to set. This
+ * method exists specifically for MRTR-aware callers that must both
populate those fields on the request (e.g.
+ * on a {@link CallToolRequest}) and branch on the response's
+ * {@code resultType} themselves, decoding into a typed result only
once {@code resultType} reads
+ * {@code "complete"}.
+ *
+ * @param method The JSON-RPC method (e.g. {@link
McpMethods#TOOLS_CALL}).
+ * @param params The fully-populated request bean.
+ * @return The raw result as a generic {@link Map}, exactly as received
over the wire, before any
+ * subclass-specific deserialization. Never <jk>null</jk> on a
successful JSON-RPC response.
+ * @throws IOException On a transport failure.
+ * @throws McpException If the server returns a JSON-RPC error.
+ */
+ @SuppressWarnings({
+ "unchecked" // Map.class.cast(...) below always yields a raw
Map from JSON deserialization.
+ })
+ public Map<String,Object> callRaw(String method, RequestParams<?>
params) throws IOException {
+ return call(method, params, Map.class, false);
+ }
+
/**
* Returns this instance's cache-partition prefix used to namespace
{@link McpResponseCache#SCOPE_PRIVATE}
* entries so two client instances sharing the same {@link
McpResponseCache} never see each other's
@@ -275,19 +303,36 @@ public final class McpClient extends AbstractMcpClient {
}
}
- // TODO C5/C6: finalize duplex return-channel header contract. Mcp-Name
is always empty here because
- // DUPLEX_RETURN_METHOD has no routing-name mapping in McpRoutingNames;
it is unclear whether the real
- // contract instead wants it to echo the correlated inbound request's
tool/prompt/resource name.
+ // C5/C6 duplex return-channel header contract remains unsettled:
Mcp-Name is always empty here because
+ // DUPLEX_RETURN_METHOD has no routing-name mapping in McpRoutingNames.
It is unclear whether the real
+ // contract instead wants it to echo the correlated inbound request's
tool/prompt/resource name; that
+ // requires spec clarification and is deliberately left open rather
than guessed at here.
private void postClientResult(Object id, JsonRpcResponse payload)
throws IOException {
var headers = Map.of("Mcp-Method", DUPLEX_RETURN_METHOD,
"Mcp-Name", McpRoutingNames.routingName(DUPLEX_RETURN_METHOD, payload));
send(new
JsonRpcRequest().setJsonrpc("2.0").setId(id).setMethod(DUPLEX_RETURN_METHOD).setParams(payload),
headers);
}
private <T> T call(String method, RequestParams<?> params, Class<T>
resultType) throws IOException {
+ return call(method, params, resultType, true);
+ }
+
+ /**
+ * Shared wire implementation for every typed method and {@link
#callRaw}.
+ *
+ * <p>
+ * {@code useCache=false} (used only by {@link #callRaw}) skips the
cache READ so a {@code callRaw} call never
+ * retrieves a differently-typed cached result (e.g. a {@link
CallToolResult} primed by {@link #callTool}) under
+ * the same cache key, which would otherwise throw a {@link
ClassCastException} on the subsequent {@code cast}.
+ * The cache WRITE at the end is unaffected by this flag: it is already
a no-op for {@code callRaw} because a
+ * plain {@link Map} result is never a {@link CacheableResult}.
+ */
+ private <T> T call(String method, RequestParams<?> params, Class<T>
resultType, boolean useCache) throws IOException {
var cacheKey = cacheKey(method, params);
- var cached = readCache(cacheKey);
- if (cached != null)
- return resultType.cast(cached);
+ if (useCache) {
+ var cached = readCache(cacheKey);
+ if (cached != null)
+ return resultType.cast(cached);
+ }
stampMeta(params.getMeta() == null ? params.setMeta(new
RequestMeta()).getMeta() : params.getMeta());
var wireParams = toWireParams(params);
var headers = Map.of("Mcp-Method", method, "Mcp-Name",
McpRoutingNames.routingName(method, wireParams));
@@ -377,7 +422,7 @@ public final class McpClient extends AbstractMcpClient {
case RequestMeta.KEY_TRACEPARENT ->
meta.setTraceparent(value);
case RequestMeta.KEY_TRACESTATE ->
meta.setTracestate(value);
case RequestMeta.KEY_BAGGAGE ->
meta.setBaggage(value);
- default -> { }
+ default -> { /* Unknown trace-context key: no
corresponding RequestMeta field to set. */ }
}
}
}
diff --git
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_CallRaw_Test.java
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_CallRaw_Test.java
new file mode 100644
index 0000000000..c08487af2f
--- /dev/null
+++
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_CallRaw_Test.java
@@ -0,0 +1,131 @@
+/*
+ * 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.client.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.util.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.bean.jsonrpc.*;
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.rest.client.*;
+import org.apache.juneau.rest.client.mcp.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link McpClient#callRaw(String, RequestParams)}: the
result-type-agnostic dispatch a
+ * Multi-Round-Trip-Request (SEP-2322) caller needs to receive an {@code
input_required} response and echo its
+ * {@code requestState}/{@code inputResponses} on a resume call (plan Phase 5,
Task 14).
+ */
+class McpClient_CallRaw_Test {
+
+ private static HttpTransport ok(String wireJson) {
+ return tReq -> TransportResponse.builder()
+ .statusCode(200)
+ .header("Content-Type", "application/json")
+ .body(new
ByteArrayInputStream(wireJson.getBytes(StandardCharsets.UTF_8)))
+ .build();
+ }
+
+ private static McpClient client(HttpTransport transport) {
+ return
McpClient.builder().endpoint("http://x/mcp").transport(transport).build();
+ }
+
+ @Test
+ void a01_callRaw_completeResult_returnsRawMapMatchingTypedCall() throws
Exception {
+ var wire =
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}],\"resultType\":\"complete\"}}";
+ try (var c = client(ok(wire))) {
+ var raw = c.callRaw(McpMethods.TOOLS_CALL, new
CallToolRequest().setName("echo").setArguments(Map.of("text", "hello")));
+ assertEquals("complete", raw.get("resultType"));
+ assertNotNull(raw.get("content"));
+ }
+ // The same wire, consumed through the typed method, yields a
CallToolResult with matching content --
+ // proving callRaw returns the identical underlying JSON, only
undiscriminated.
+ try (var c = client(ok(wire))) {
+ var typed = c.callTool("echo", Map.of("text", "hello"));
+ assertEquals("hello", ((TextContent)
typed.getContent().get(0)).getText());
+ }
+ }
+
+ @Test
+ void
a02_callRaw_inputRequiredResult_exposesRequestStateAndInputRequests() throws
Exception {
+ var wire =
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"inputRequests\":{\"q1\":{\"type\":\"elicitation\"}},\"requestState\":\"tok-abc\",\"resultType\":\"input_required\"}}";
+ try (var c = client(ok(wire))) {
+ var raw = c.callRaw(McpMethods.TOOLS_CALL, new
CallToolRequest().setName("ask"));
+ assertEquals("input_required", raw.get("resultType"));
+ assertEquals("tok-abc", raw.get("requestState"));
+ assertInstanceOf(Map.class, raw.get("inputRequests"));
+ assertTrue(((Map<?,?>)
raw.get("inputRequests")).containsKey("q1"));
+ }
+ }
+
+ @Test
+ void a03_callRaw_jsonRpcError_throwsMcpExceptionLikeEveryTypedMethod()
throws Exception {
+ var wire =
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32602,\"message\":\"bad
params\"}}";
+ try (var c = client(ok(wire))) {
+ var e = assertThrows(McpException.class,
+ () -> c.callRaw(McpMethods.TOOLS_CALL, new
CallToolRequest().setName("ask").setRequestState("stale")));
+ assertEquals(-32602, e.getCode());
+ }
+ }
+
+ /**
+ * Reproduces the mixed-API cache-key collision: a typed call caches a
non-{@link Map} result bean under a key
+ * that {@code callRaw} would compute identically for the same
method+params. {@code CallToolResult} (the
+ * {@code tools/call} result type) is never actually written to the
cache because it doesn't implement
+ * {@link CacheableResult}, so {@code resources/read} (whose
+ * {@link ReadResourceResult} does implement it) is the type that is
genuinely reachable end-to-end: prime the
+ * cache via the typed {@link McpClient#readResource} call, then issue
{@link McpClient#callRaw} for the same
+ * {@code resources/read} URI. Before the fix, {@code callRaw}'s cache
READ would return the cached
+ * {@code ReadResourceResult} and {@code Map.class.cast(...)} it,
throwing {@link ClassCastException}.
+ */
+ @Test
+ void a04_callRaw_doesNotReadTypedResultFromSharedCacheKey() throws
Exception {
+ var calls = new AtomicInteger();
+ HttpTransport transport = tReq -> {
+ var n = calls.incrementAndGet();
+ var wire = n == 1
+ ?
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"contents\":[{\"type\":\"resourceText\",\"uri\":\"file:///a\",\"text\":\"cached-body\"}],\"ttlMs\":60000}}"
+ :
"{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"contents\":[{\"type\":\"resourceText\",\"uri\":\"file:///a\",\"text\":\"wire-body\"}]}}";
+ return TransportResponse.builder()
+ .statusCode(200)
+ .header("Content-Type", "application/json")
+ .body(new
ByteArrayInputStream(wire.getBytes(StandardCharsets.UTF_8)))
+ .build();
+ };
+ try (var c = McpClient.builder()
+ .endpoint("http://x/mcp")
+ .transport(transport)
+ .responseCache(new InMemoryMcpResponseCache())
+ .build()) {
+ // Primes the cache with a typed ReadResourceResult
under the resources/read + uri cache key.
+ c.readResource("file:///a");
+ assertEquals(1, calls.get());
+
+ // callRaw on the same method+params must not retrieve
the cached ReadResourceResult and must not throw
+ // ClassCastException; it goes to the wire instead,
proven by the second (distinct) wire body winning.
+ var raw = assertDoesNotThrow(() ->
c.callRaw(McpMethods.RESOURCES_READ, new
ReadResourceRequest().setUri("file:///a")));
+ assertEquals(2, calls.get());
+ var contents = (List<?>) raw.get("contents");
+ var content = (Map<?,?>) contents.get(0);
+ assertEquals("wire-body", content.get("text"));
+ }
+ }
+}
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 f0ac386572..ce1604b2ef 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
@@ -640,11 +640,15 @@ public final class McpRevision implements
org.apache.juneau.rest.server.mcp.McpR
* @return The resolved MRTR context. Never <jk>null</jk>.
*/
private MrtrContext resolveMrtrContext(String method,
Map<String,Object> params, BeanStore ctx) {
- var wrapped = new BasicBeanStore(ctx)
- .addBean(McpMrtrCapabilityContext.class, new
McpMrtrCapabilityContext(clientElicitationSupported(params)));
var requestState = McpParamUtils.strParam(params,
"requestState");
- if (requestState == null)
+ if (requestState == null) {
+ var wrapped = new BasicBeanStore(ctx)
+ .addBean(McpMrtrCapabilityContext.class, new
McpMrtrCapabilityContext(clientElicitationSupported(params)));
return new MrtrContext(wrapped, 0);
+ }
+ // Validate the echoed requestState before constructing the
BasicBeanStore below: every failure here
+ // throws, and a BasicBeanStore built earlier would never reach
a caller's try-with-resources to be
+ // closed (java:S2095). Deferring construction until validation
succeeds means no path leaks it.
var sealed = mrtrConfig.getCodec().unseal(requestState,
aad(method))
.orElseThrow(() -> new
McpException(CODE_INVALID_PARAMS, "Invalid or tampered requestState"));
if (! method.equals(sealed.method()))
@@ -655,8 +659,10 @@ public final class McpRevision implements
org.apache.juneau.rest.server.mcp.McpR
// Client-facing message deliberately omits the
configured cap value so server config is not leaked.
throw new McpException(CODE_MAX_ROUNDS_EXCEEDED, "Max
MRTR rounds exceeded");
var inputResponses = McpParamUtils.mapParam(params,
"inputResponses");
- var store = wrapped.addBean(McpMrtrResumeContext.class, new
McpMrtrResumeContext(sealed.continuation(), inputResponses));
- return new MrtrContext(store, sealed.round());
+ var wrapped = new BasicBeanStore(ctx)
+ .addBean(McpMrtrCapabilityContext.class, new
McpMrtrCapabilityContext(clientElicitationSupported(params)))
+ .addBean(McpMrtrResumeContext.class, new
McpMrtrResumeContext(sealed.continuation(), inputResponses));
+ return new MrtrContext(wrapped, sealed.round());
}
/**
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
index 6e65a87702..da3d05cdb6 100644
---
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
@@ -19,19 +19,25 @@ package org.apache.juneau.rest.server.mcp.v20260728;
import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
+import java.nio.charset.*;
import java.nio.file.*;
import java.util.*;
import java.util.function.*;
+import javax.crypto.*;
+import javax.crypto.spec.*;
+
import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.commons.inject.*;
import org.apache.juneau.http.tracing.TraceContextCarrier;
import org.apache.juneau.marshall.collections.*;
import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.marshaller.Json;
import org.apache.juneau.rest.mock.classic.*;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.mcp.*;
import org.apache.juneau.rest.server.tracing.*;
+import org.junit.jupiter.api.*;
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;
@@ -273,6 +279,84 @@ class Characterization_Test {
}
}
+ /**
+ * Backs every {@code MRTR-*} fixture (SEP-2322 Multi-Round-Trip
Requests): one {@code ask} tool that pauses
+ * (throws {@link McpInputRequiredSignal}) on a first-round call, and
on RESUME either completes (echoing the
+ * client's {@code inputResponses}) or pauses a second time —
branched on the decoded continuation so a
+ * single handler drives the complete / pause-again / max-rounds
fixtures.
+ *
+ * <p>
+ * Wires a <b>deterministic, fixed-key</b> {@link FixedKeyGcmCodec}
instead of the production ephemeral-key
+ * {@link AeadRequestStateCodec}, so a {@code requestState} the harness
seals for a RESUME fixture (see
+ * {@link #mrtrToken(String)}) is unsealable by this servlet across a
fresh process — a property the
+ * random-per-process production key deliberately does <b>not</b> have.
The opaque token minted <em>into</em> a
+ * PAUSE response still embeds a wall-clock expiry, so responses are
compared with the token value redacted (see
+ * {@link #normalizeRequestState(String)}); the fixtures pin the wire
<em>shape</em>, never the opaque bytes.
+ */
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class,
defaultAccept = "application/json")
+ public static class F_Mrtr extends McpRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Override protected McpServerConfig createMcpConfig() {
+ return new McpServerConfig().addTool(new
McpToolHandler() {
+ @Override public McpToolSpec descriptor() {
return new McpToolSpec().setName("ask").setDescription("desc:ask"); }
+ @Override public McpToolOutcome
call(Map<String,Object> arguments, BeanStore ctx) {
+ var resume =
ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty())
+ throw new
McpInputRequiredSignal(Map.of("q1", Map.of("type", "elicitation")), "cont-1");
+ if
("pause-again".equals(resume.get().continuation()))
+ throw new
McpInputRequiredSignal(Map.of("q2", Map.of("type", "elicitation")), "cont-2");
+ return McpToolOutcome.text("resumed:" +
resume.get().inputResponses().get("q1"));
+ }
+ });
+ }
+ @Override protected McpMrtrConfig createMrtrConfig() {
+ return new McpMrtrConfig().setCodec(new
FixedKeyGcmCodec());
+ }
+ }
+
+ /**
+ * Test-only deterministic {@link RequestStateCodec}: AES-256-GCM with
a <b>hardcoded</b> key and nonce.
+ *
+ * <p>
+ * Unlike the production {@link AeadRequestStateCodec} (random
per-process key + random per-seal nonce), this
+ * codec produces identical ciphertext for identical {@code (state,
aad)} inputs and can unseal a token sealed by
+ * any other instance — exactly the reproducibility a committed
characterization fixture needs. It is still
+ * genuinely tamper-evident (the GCM auth tag rejects the {@code
MRTR-tampered-request-state} corruption) and
+ * honors the AAD binding. Fixed-nonce AES-GCM is catastrophically
insecure for real traffic; it is used here
+ * <em>solely</em> because determinism, not confidentiality, is the
goal in a fixture.
+ */
+ static final class FixedKeyGcmCodec implements RequestStateCodec {
+ private static final SecretKey KEY = new SecretKeySpec(new
byte[32], "AES");
+ private static final byte[] NONCE = new byte[12];
+ private static final Base64.Encoder B64 =
Base64.getUrlEncoder().withoutPadding();
+
+ @Override public String seal(McpRequestState state, String aad)
{
+ try {
+ var cipher =
Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.ENCRYPT_MODE, KEY, new
GCMParameterSpec(128, NONCE));
+
cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
+ var ciphertext =
cipher.doFinal(Json.of(state).getBytes(StandardCharsets.UTF_8));
+ return B64.encodeToString(NONCE) + "." +
B64.encodeToString(ciphertext);
+ } catch (Exception e) {
+ throw new RuntimeException("Fixture codec seal
failed", e);
+ }
+ }
+
+ @Override public Optional<McpRequestState> unseal(String token,
String aad) {
+ try {
+ var parts = token.split("\\.", 2);
+ var nonce =
Base64.getUrlDecoder().decode(parts[0]);
+ var ciphertext =
Base64.getUrlDecoder().decode(parts[1]);
+ var cipher =
Cipher.getInstance("AES/GCM/NoPadding");
+ cipher.init(Cipher.DECRYPT_MODE, KEY, new
GCMParameterSpec(128, nonce));
+
cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
+ return Optional.of(Json.to(new
String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8),
McpRequestState.class));
+ } catch (@SuppressWarnings("unused") Exception e) {
+ return Optional.empty();
+ }
+ }
+ }
+
// --- fixture handler factories
---------------------------------------------------------
private static McpToolHandler tool(String name, McpSchema schema,
Function<Map<String,Object>,McpToolOutcome> fn) {
@@ -331,10 +415,74 @@ class Characterization_Test {
"COMPLETE-empty-unknown", "COMPLETE-capped" ->
F_Complete.class;
case "TRACE-meta-parent", "TRACE-http-fallback",
"TRACE-meta-wins",
"TRACE-tracestate-baggage",
"TRACE-response-echo", "TRACE-jsonrpc-error" -> F_Traced.class;
+ case "MRTR-input-required-response",
"MRTR-resume-complete", "MRTR-resume-input-required-again",
+ "MRTR-tampered-request-state",
"MRTR-expired-request-state", "MRTR-unsupported-capability",
+ "MRTR-max-rounds-exceeded" -> F_Mrtr.class;
default -> F_Empty.class;
};
}
+ // --- MRTR requestState sealing/redaction
------------------------------------------------
+
+ /** Placeholder in an {@code MRTR-*} resume fixture's committed request
body, replaced at replay time. */
+ private static final String TOKEN_PLACEHOLDER = "__REQUEST_STATE__";
+
+ // Fixed expiry constants embedded in a sealed requestState so a RESUME
fixture's token is reproducible
+ // (never "now + ttl"): far-future for a still-valid token, a fixed
past instant for the expired fixture.
+ private static final long FAR_FUTURE_MS = 32503680000000L; // ~ year
3000
+ private static final long PAST_MS = 1000L;
+
+ private static String aad(String method) {
+ return method + '\u0000' + McpProtocol.VERSION_2026_07_28;
+ }
+
+ /**
+ * The {@code requestState} the harness seals (with {@link
FixedKeyGcmCodec}, matching {@link F_Mrtr}'s codec)
+ * for each RESUME-family fixture. Substituted into the committed
request in place of {@link #TOKEN_PLACEHOLDER}
+ * so the token stays framework-owned/opaque while the request pins the
surrounding wire shape.
+ *
+ * <p>
+ * {@code MRTR-resume-complete} and {@code
MRTR-resume-input-required-again} have byte-identical committed
+ * request files by design: the behavioral difference between the two
fixtures lives entirely inside the sealed
+ * opaque token's continuation value ({@code "complete-me"} vs {@code
"pause-again"}), not in anything visible
+ * on the wire request body.
+ *
+ * <p>
+ * The plan's original "commit the corrupted requestState as a frozen
literal" constraint for
+ * {@code MRTR-tampered-request-state} was consciously superseded:
because {@link FixedKeyGcmCodec} makes
+ * {@code seal(...)} fully deterministic (fixed key, fixed nonce),
regenerating the tampered token on every run
+ * via {@code tamper(codec.seal(...))} is equally reproducible and
avoids hand-maintaining opaque ciphertext.
+ */
+ private static String mrtrToken(String fixture) {
+ var codec = new FixedKeyGcmCodec();
+ return switch (fixture) {
+ case "MRTR-resume-complete" -> codec.seal(new
McpRequestState("complete-me", "tools/call", 1, FAR_FUTURE_MS),
aad("tools/call"));
+ case "MRTR-resume-input-required-again" ->
codec.seal(new McpRequestState("pause-again", "tools/call", 1, FAR_FUTURE_MS),
aad("tools/call"));
+ case "MRTR-expired-request-state" -> codec.seal(new
McpRequestState("cont-1", "tools/call", 1, PAST_MS), aad("tools/call"));
+ case "MRTR-max-rounds-exceeded" -> codec.seal(new
McpRequestState("cont-1", "tools/call", McpMrtrConfig.DEFAULT_MAX_ROUNDS,
FAR_FUTURE_MS), aad("tools/call"));
+ case "MRTR-tampered-request-state" ->
tamper(codec.seal(new McpRequestState("cont-1", "tools/call", 1,
FAR_FUTURE_MS), aad("tools/call")));
+ default -> throw new IllegalArgumentException("No MRTR
token mapping for fixture: " + fixture);
+ };
+ }
+
+ /** Flips one ciphertext byte of a valid token so its GCM tag fails
verification (the tamper fixture). */
+ private static String tamper(String token) {
+ var parts = token.split("\\.", 2);
+ var ciphertext = Base64.getUrlDecoder().decode(parts[1]);
+ ciphertext[0] ^= 1;
+ return parts[0] + "." +
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext);
+ }
+
+ /**
+ * Redacts the opaque {@code requestState} token value in a response
body to a fixed placeholder before
+ * comparison. The token is per-process-nondeterministic ciphertext
(production uses a random key and embeds a
+ * wall-clock expiry), so pinning its exact bytes is neither possible
nor meaningful; the fixture pins its
+ * presence and the surrounding wire shape instead.
+ */
+ private static String normalizeRequestState(String responseBody) {
+ return responseBody.replaceAll("\"requestState\":\"[^\"]+\"",
"\"requestState\":\"<sealed>\"");
+ }
+
// --- replay
----------------------------------------------------------------------------
static List<String> fixtures() throws Exception {
@@ -350,7 +498,7 @@ class Characterization_Test {
@ParameterizedTest
@MethodSource("fixtures")
void a01_wireIsUnchanged(String fixture) throws Exception {
- var actual = replayHttp(fixture);
+ var actual = normalizeRequestState(replayHttp(fixture));
var expected = DIR.resolve(fixture + ".response.json");
if (WRITE) {
Files.writeString(expected, actual);
@@ -360,8 +508,27 @@ class Characterization_Test {
() -> fixture + ": WIRE FORMAT CHANGED. Do not update
the fixture — fix the code.");
}
+ /**
+ * The opaque {@code requestState} minted into {@code
MRTR-resume-input-required-again}'s second PAUSE is
+ * redacted for {@code a01}'s byte comparison, so its embedded round
counter is verified here instead: unseal
+ * the live token with the same fixed-key codec {@link F_Mrtr} uses and
assert the round advanced to 2 (plan
+ * Phase 4 Task 12 decode-and-check).
+ */
+ @Test
+ void a02_resumeInputRequiredAgain_sealedRoundIsTwo() throws Exception {
+ var raw = replayHttp("MRTR-resume-input-required-again");
+ var envelope = Json.to(raw, JsonMap.class);
+ var result = (Map<?,?>) envelope.get("result");
+ var token = (String) result.get("requestState");
+ var state = new FixedKeyGcmCodec().unseal(token,
aad("tools/call")).orElseThrow();
+ assertEquals(2, state.round());
+ assertEquals("cont-2", state.continuation());
+ }
+
private String replayHttp(String fixture) throws Exception {
var requestBody = Files.readString(DIR.resolve(fixture +
".request.json")).strip();
+ if (requestBody.contains(TOKEN_PLACEHOLDER))
+ requestBody = requestBody.replace(TOKEN_PLACEHOLDER,
mrtrToken(fixture));
var headers = loadHeaders(fixture);
var client = MockRestClient.create(servletFor(fixture)).json()
.contentType("application/json").accept("application/json").ignoreErrors().build();
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-expired-request-state.headers.properties
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-expired-request-state.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-expired-request-state.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-expired-request-state.request.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-expired-request-state.request.json
new file mode 100644
index 0000000000..97fc1a6f99
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-expired-request-state.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-expired-request-state.response.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-expired-request-state.response.json
new file mode 100644
index 0000000000..a1727204b3
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-expired-request-state.response.json
@@ -0,0 +1 @@
+{"error":{"code":-32022,"message":"requestState has
expired"},"id":1,"jsonrpc":"2.0"}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-input-required-response.headers.properties
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-input-required-response.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-input-required-response.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-input-required-response.request.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-input-required-response.request.json
new file mode 100644
index 0000000000..0e2aa4847b
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-input-required-response.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-input-required-response.response.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-input-required-response.response.json
new file mode 100644
index 0000000000..6f7cc8908b
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-input-required-response.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"inputRequests":{"q1":{"type":"elicitation"}},"requestState":"<sealed>","resultType":"input_required"}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-max-rounds-exceeded.headers.properties
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-max-rounds-exceeded.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-max-rounds-exceeded.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-max-rounds-exceeded.request.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-max-rounds-exceeded.request.json
new file mode 100644
index 0000000000..97fc1a6f99
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-max-rounds-exceeded.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-max-rounds-exceeded.response.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-max-rounds-exceeded.response.json
new file mode 100644
index 0000000000..b3eef6825e
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-max-rounds-exceeded.response.json
@@ -0,0 +1 @@
+{"error":{"code":-32023,"message":"Max MRTR rounds
exceeded"},"id":1,"jsonrpc":"2.0"}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-complete.headers.properties
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-complete.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-complete.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-complete.request.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-complete.request.json
new file mode 100644
index 0000000000..2e2ecabcba
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-complete.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","inputResponses":{"q1":"answer"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-complete.response.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-complete.response.json
new file mode 100644
index 0000000000..763cc3f7cc
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-complete.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"content":[{"type":"text","text":"resumed:answer"}],"resultType":"complete"}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-input-required-again.headers.properties
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-input-required-again.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-input-required-again.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-input-required-again.request.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-input-required-again.request.json
new file mode 100644
index 0000000000..2e2ecabcba
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-input-required-again.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","inputResponses":{"q1":"answer"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-input-required-again.response.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-input-required-again.response.json
new file mode 100644
index 0000000000..0980582389
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-resume-input-required-again.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"inputRequests":{"q2":{"type":"elicitation"}},"requestState":"<sealed>","resultType":"input_required"}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-tampered-request-state.headers.properties
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-tampered-request-state.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-tampered-request-state.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-tampered-request-state.request.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-tampered-request-state.request.json
new file mode 100644
index 0000000000..97fc1a6f99
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-tampered-request-state.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","requestState":"__REQUEST_STATE__","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-tampered-request-state.response.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-tampered-request-state.response.json
new file mode 100644
index 0000000000..1157a8dfb7
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-tampered-request-state.response.json
@@ -0,0 +1 @@
+{"error":{"code":-32602,"message":"Invalid or tampered
requestState"},"id":1,"jsonrpc":"2.0"}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-unsupported-capability.headers.properties
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-unsupported-capability.headers.properties
new file mode 100644
index 0000000000..4c862e26b1
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-unsupported-capability.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=ask
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-unsupported-capability.request.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-unsupported-capability.request.json
new file mode 100644
index 0000000000..1c5c5da477
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-unsupported-capability.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ask","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
\ No newline at end of file
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-unsupported-capability.response.json
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-unsupported-capability.response.json
new file mode 100644
index 0000000000..56242140a5
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/MRTR-unsupported-capability.response.json
@@ -0,0 +1 @@
+{"error":{"code":-32021,"message":"Client does not advertise the elicitation
capability required for input_required"},"id":1,"jsonrpc":"2.0"}
\ No newline at end of file