Skip to main content

juneau-rest-server-mcp

New to MCP in Juneau? Start with the MCP Quickstart and MCP Recipes — this page is the deep reference for lookup once you're past the basics.

Want a runnable, end-to-end example instead?

juneau-examples-mcp is a complete, copy-me Maven module exercising every major surface on this page — tools, elicitation/MRTR, prompts + completion, resources + resource templates, and subscriptions — against a tiny in-memory "notes" service, targeting revision 2026-07-28. It ships as a downloadable project zip in the Apache release. See MCP Recipes for the full rundown.

juneau-rest-server-mcp is a revision-neutral core for exposing a Model Context Protocol (MCP) JSON-RPC endpoint on juneau-rest-server. On its own it has zero compile-time knowledge of any MCP protocol revision; a protocol revision is supplied by a separate adapter module — juneau-rest-server-mcp-v20250618 or juneau-rest-server-mcp-v20260728. This page covers all three modules. See Choosing your MCP setup for why the two adapters are separate artifacts and why v20260728 keeps growing larger than v20250618 rather than the two converging.

Overview​

MCP is a JSON-RPC 2.0 protocol that lets AI assistants discover and invoke external tools, prompts, and resources. Together, the two modules provide:

  • A revision SPI (McpRevision) that a protocol revision implements — the neutral core dispatches through it without knowing what's on the other side.
  • A drop-in servlet (McpRestServlet, concretely org.apache.juneau.rest.server.mcp.v20250618.McpRestServlet) for the common case of "expose an MCP endpoint at POST /mcp".
  • An interface mixin (McpEndpoint, concretely org.apache.juneau.rest.server.mcp.v20250618.McpEndpoint) for embedding an MCP endpoint inside an existing @Rest resource.
  • Functional handler interfaces for tools/prompts/resources, typed against a revision-neutral model, plus a revision-neutral typed tool bridge (McpTypedToolHandler, in the core) that derives input/output schemas and does argument binding and result canonicalization for you. Typed prompts remain an adapter-specific sugar layer.
  • A pluggable pagination seam for */list methods.

The split exists so that a future MCP protocol revision can be added as a second adapter module without touching the JSON-RPC envelope or this neutral core — enforced by a maven-enforcer-plugin banned-dependency rule on the core module's pom.xml that fails the build if juneau-rest-server-mcp ever depends on juneau-bean-mcp-* or juneau-rest-server-mcp-*. There's deliberately no ServiceLoader auto-discovery (unlike the transport-provider pattern in juneau-rest-client-apache-httpclient-45): a consumer binds exactly one revision at compile time by extending that revision's abstract servlet or composing its endpoint mixin.

The implementation is stateless — every request is dispatched against the McpServerConfig supplied by the REST adapter (via createMcpConfig() or an McpEndpoint implementation), and per-request state (such as the underlying RestRequest) is handed to handlers as a BeanStore.

Architecture​

org.apache.juneau.rest.server.mcp.v20250618.McpRevision implements McpRevision and owns this revision's JSON-RPC method table and error-code table — it replaces the earlier, now-deleted McpDispatcher/Mcp façade. The adapter servlet/mixin only translates from RestRequest to an McpExchange + BeanStore, then calls revision().dispatch(...).

Getting Started​

Add the dependency​

Application code normally depends on the 2026-07-28 adapter, which transitively pulls in the core plus the 2026-07-28 wire beans:

<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-mcp-v20260728</artifactId>
<version>${juneau.version}</version>
</dependency>

This module transitively pulls in juneau-rest-server-mcp (the core) and juneau-bean-mcp-v20260728.

Drop-in servlet​

Subclass org.apache.juneau.rest.server.mcp.v20260728.McpRestServlet (not the abstract core AbstractMcpRestServlet directly) and supply your config in createMcpConfig(). The base class wires up @Rest, @SerializerConfig(addBeanTypes="true"), and a POST / handler:

import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.mcp.*;
import org.apache.juneau.rest.server.mcp.v20260728.*;

@Rest(path="/mcp")
public class MyMcpServlet extends org.apache.juneau.rest.server.mcp.v20260728.McpRestServlet {

@Override
protected McpServerConfig createMcpConfig() {
return new McpServerConfig()
.setName("my-server")
.setVersion("1.0.0")
.addTool(new EchoTool());
}
}

Mount the servlet through your microservice / Spring Boot config like any other @Rest resource.

Interface mixin​

If you already have a @Rest resource and want to expose MCP at POST /mcp next to your other endpoints, implement McpEndpoint:

@Rest(path="/api")
public class MyResource extends BasicRestServlet implements McpEndpoint {

@Override
public McpServerConfig getMcpConfig() {
return new McpServerConfig().addTool(new EchoTool());
}
}

The default handleMcpRequest(...) method on McpEndpoint (inherited from the revision-neutral McpEndpointMixin, annotated @RestPost("/mcp")) takes care of dispatch, via org.apache.juneau.rest.server.mcp.v20260728.McpEndpoint's revision() override.

Running Under Spring Boot​

Both dated adapters ship a Spring Boot convenience base class, SpringMcpRestServlet — org.apache.juneau.rest.server.mcp.v20250618.SpringMcpRestServlet and org.apache.juneau.rest.server.mcp.v20260728.SpringMcpRestServlet — combining the revision's McpEndpoint mixin with BasicSpringRestServlet. This is the simplest way to expose MCP from a Spring Boot resource with full Spring dependency-injection support inside your tool/prompt/resource handlers.

import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.mcp.*;
import org.apache.juneau.rest.server.mcp.v20260728.*;

@Rest(path="/mcp")
public class MyMcpResource extends SpringMcpRestServlet {

@Override
public McpServerConfig getMcpConfig() {
return new McpServerConfig().addTool(new MySpringAwareTool());
}
}

You get MCP dispatch (McpEndpoint) plus the Spring bean-store bridge (BasicSpringRestServlet, backed by SpringBeanStore) in one class — just implement getMcpConfig(). A handler like MySpringAwareTool can then resolve Spring-managed beans via BeanStore.getBean(MySpringService.class).

General mixin pattern​

If you already have your own Spring Boot base resource — one that already extends BasicSpringRestServlet (or a subclass of it) — compose the same mixin directly instead of going through SpringMcpRestServlet:

@Rest(path="/api")
public class MyResource extends BasicSpringRestServlet implements McpEndpoint {

@Override
public McpServerConfig getMcpConfig() {
return new McpServerConfig().addTool(new MySpringAwareTool());
}
}

This is exactly the shape SpringMcpRestServlet uses internally — reach for it directly when your resource needs to extend some other Spring Boot base class.

Don't subclass McpRestServlet under Spring Boot

It's tempting to reuse the plain drop-in servlet (extends McpRestServlet, see "Drop-in servlet" above) from a Spring Boot app. Don't. McpRestServlet extends BasicRestServlet — a sibling of SpringRestServlet, not a subclass of it — so a class can never extend both, and even setting that aside, only the mixin path (BasicSpringRestServlet implements McpEndpoint) carries the Spring bean-store bridge MCP handlers rely on. A servlet that extends McpRestServlet inside a Spring Boot app still registers and answers MCP requests — dispatch keeps working — but every BeanStore.getBean(MySpringService.class) call inside a tool/prompt/resource handler silently comes back empty, because that servlet was never wired to Spring's ApplicationContext. There's no compile-time signal; the gap only surfaces at runtime when a handler's Spring lookup returns nothing.

Maven dependencies​

<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-springboot</artifactId>
<version>${juneau.version}</version>
</dependency>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-mcp-v20260728</artifactId>
<version>${juneau.version}</version>
</dependency>
<!-- and/or juneau-rest-server-mcp-v20250618, if you also need that revision -->

Which dated adapter you depend on — and therefore which SpringMcpRestServlet/McpEndpoint you extend/implement (org.apache.juneau.rest.server.mcp.v20250618.* or org.apache.juneau.rest.server.mcp.v20260728.*) — is a compile-time choice, exactly like every other MCP binding on this page. Both revisions ship the same convenience class.

Servlet registration​

Registration is standard Juneau-on-Spring-Boot — nothing MCP-specific. Either enable opt-in auto-registration:

juneau.rest.auto-register=true

or register explicitly with the self-declared-path helper:

@Bean
ServletRegistrationBean<?> mcpServlet(BeanStore beanStore) {
return JuneauServletRegistrations.forServlet(new MyMcpResource(), beanStore);
}

See REST Server — Self-Registration for the full mechanism (Jetty microservice and web.xml equivalents included) and juneau-rest-server-springboot / Spring Boot Overview for the Spring bean-store bridge these classes build on.

Contrast: the Juneau Microservice runtime​

Under the Jetty-based Juneau Microservice runtime there is no Spring bean store at all, so the caveat above doesn't apply: MyMcpServlet extends McpRestServlet (see "Drop-in servlet" above) is the ordinary, correct recipe there — there's no second bean-resolution path to silently miss.

Writing Handlers​

Raw tool handler​

McpToolHandler is the lowest-level handler interface, and is revision-neutral — it's typed against McpToolSpec/McpToolOutcome, not any revision's wire beans. It receives a parsed Map<String, Object> of arguments and a per-request BeanStore:

import java.util.*;

import org.apache.juneau.commons.inject.*;
import org.apache.juneau.rest.server.mcp.*;

public class EchoTool implements McpToolHandler {
@Override
public McpToolSpec descriptor() {
return new McpToolSpec()
.setName("echo")
.setDescription("Echoes the supplied text back.");
}

@Override
public McpToolOutcome call(Map<String, Object> arguments, BeanStore ctx) {
String text = (String) arguments.getOrDefault("text", "");
return McpToolOutcome.text(text);
}
}

A tool's inputSchema is an McpSchema — an unconstrained JSON-object carrier, built from a JsonMap:

import org.apache.juneau.marshall.collections.*;
import org.apache.juneau.rest.server.mcp.*;

McpToolSpec echo = new McpToolSpec()
.setName("echo")
.setDescription("Echoes the input text back.")
.setInputSchema(McpSchema.of(JsonMap.of(
"type", "object",
"properties", JsonMap.of("text", JsonMap.of("type", "string")),
"required", List.of("text")
)));

The 2025-06-18 adapter's wire JsonSchema bean only supports six keywords (type, properties, required, additionalProperties, items, $defs) — see "Tool schema validation" below.

Typed tool handler (sugar)​

When you'd rather receive a Juneau bean for arguments and return one for the result — with both an input and output JSON Schema derived for you — implement the revision-neutral McpTypedToolHandler<A,R> and register it directly. Unlike typed prompts, typed tools are not adapter-specific: one handler serves either dated adapter unchanged.

import java.lang.reflect.*;

import org.apache.juneau.commons.inject.*;
import org.apache.juneau.rest.server.mcp.*;

public class WeatherArgs {
public String city;
public String unit = "C";
}

public class WeatherResult {
public String summary;
public double temp;
}

public class WeatherTool implements McpTypedToolHandler<WeatherArgs, WeatherResult> {
@Override
public McpToolSpec descriptor() {
return new McpToolSpec()
.setName("weather")
.setDescription("Looks up the current weather for a city.");
}

@Override
public Type argumentType() { return WeatherArgs.class; }

@Override
public Type resultType() { return WeatherResult.class; }

@Override
public WeatherResult call(WeatherArgs args, BeanStore ctx) {
WeatherResult r = new WeatherResult();
r.summary = "Sunny";
r.temp = 23.5;
return r;
}
}

config.addTool(new WeatherTool());

The core bridge (McpTypedHandlers.adaptTool(...), invoked implicitly by the singular McpServerConfig.addTool(McpTypedToolHandler) overload above):

  1. Derives inputSchema from argumentType() and outputSchema from resultType() with juneau-marshall's JsonSchemaGenerator, including $defs for referenced bean types. Schemas you set manually on descriptor() are replaced by the derived ones, so the declared types and the advertised schema can never drift.
  2. JSON-binds the inbound argument map into argumentType(); a binding failure is a JSON-RPC -32602 Invalid params error naming the argument type.
  3. Invokes your typed call(...).
  4. Canonicalizes the returned R to a JSON tree in a single serialization pass — with addBeanTypes(false) so no _type discriminator leaks into structured data — bounds the tree, and assigns it to McpToolOutcome.structuredContent.
  5. By default, mirrors that same canonical tree as one compact-JSON TextContent block for backward compatibility with clients that only read content. Override mirrorStructuredContentAsText() to return false and suppress the mirrored text block.

Both dated adapters map outputSchema/structuredContent on the wire, but with different constraints, matching MCP's own history: structured tool output was introduced in 2025-06-18 (Tool.outputSchema, CallToolResult.structuredContent), constrained to object-shaped schemas and a JSON-object structuredContent value, and broadened in 2026-07-28 to arbitrary JSON Schema Draft 2020-12 shapes and any JSON value. Concretely:

  • The 2025-06-18 adapter validates both schemas against its six-keyword, object-rooted JsonSchema bean at first dispatch (see "Tool schema validation" below) and requires structuredContent to be a JSON object; a typed tool returning a bare scalar/array works under 2026-07-28 but is rejected at 2025-06-18's first dispatch.
  • The 2026-07-28 adapter maps both fields through its unrestricted Draft 2020-12 JsonSchema<?> bean and accepts any JSON value for structuredContent.

Both dated servlets and mixins apply identical serializer policy centrally (@SerializerConfig(addBeanTypes="true", uriResolution="NONE") on the neutral operations), so a typed tool produces byte-identical structured output and mirrored text whether it's registered behind a servlet or an endpoint mixin.

Prompts and resources​

McpPromptHandler, McpResourceHandler (core, neutral) and McpTypedPromptHandler<A> (adapter, typed sugar) follow the same shape. Register raw/adapted handlers via addPrompt(...) and addResource(...) on McpServerConfig. There is no McpTypedResourceHandler — this asymmetry pre-exists and applies to both the pre- and post-re-layering shape.

Reporting errors​

Throw McpException (from juneau-bean-jsonrpc) from any handler to surface a structured JSON-RPC error:

import org.apache.juneau.bean.jsonrpc.*;
import org.apache.juneau.rest.server.mcp.v20250618.*;

throw new McpException(McpRevision.CODE_INVALID_PARAMS, "Missing required argument 'city'");

McpException carries the JSON-RPC code, message, and optional data fields. McpRevision converts other unchecked exceptions into -32603 Internal error automatically.

Pagination​

McpServerConfig.setCursor(McpCursor) controls how tools/list, prompts/list, and resources/list paginate. McpCursor/McpPage are revision-neutral and live in the core, unchanged by the re-layering.

Built-in strategies​

import org.apache.juneau.rest.server.mcp.*;

config.setCursor(McpCursor.SINGLE_PAGE); // default - one page, no cursor
config.setCursor(McpCursor.fixedSize(50)); // opaque integer-offset paging, 50 per page

Custom strategy​

McpCursor is a @FunctionalInterface. Implement it to plug in arbitrary pagination (database cursors, HMAC-signed tokens, etc.):

McpCursor myCursor = new McpCursor() {
@Override
public <T> McpPage<T> page(List<T> all, String cursor, BeanStore ctx) {
// ... your logic ...
return new McpPage<>(items, nextOpaqueToken);
}
};

config.setCursor(myCursor);

McpPage<T> is a record holding the slice items and an opaque nextCursor (null to indicate end-of-results).

Notifications​

JSON-RPC requests with id == null are notifications. McpRevision.dispatch() invokes the handler, silently swallows any exception, and returns null. The REST servlet/mixin renders this as an empty response body, so transports can map this to 204 No Content.

Capabilities​

Capabilities are revision-owned, not part of the neutral McpServerConfig — ServerCapabilities is a 2025-06-18 wire type, and capability shape is expected to diverge across future MCP revisions. McpRestServlet and McpEndpoint each expose a capabilities() hook:

@Rest(path="/mcp")
public class MyMcpServlet extends McpRestServlet {
@Override
protected McpServerConfig createMcpConfig() {
return new McpServerConfig().addTool(new EchoTool());
}

@Override
protected ServerCapabilities capabilities() {
return new ServerCapabilities().setLogging(new LoggingCapability());
}
}

Returning null (the default) leaves capabilities auto-derived from the registered tool/prompt/resource lists — McpRevision synthesizes a bare ServerCapabilities advertising tools/prompts/resources only when at least one matching handler is registered, exactly as the earlier McpDispatcher did. Returning a non-null value bypasses auto-derivation entirely — the way to advertise resources.subscribe, logging, listChanged, or experimental, none of which are derivable from a handler registry. McpEndpoint exposes the equivalent hook as a default interface method for the mixin path.

Server Info Defaults​

McpServerConfig has plain name/version String fields (setName(...)/setVersion(...)) rather than a dedicated identity bean. If neither is set, McpRevision reports:

  • name = "juneau-rest-server-mcp"
  • version = "unknown"

Always set your own name/version so MCP clients can identify and version your service.

Tool schema validation​

New behavior added by the re-layering. On a given McpServerConfig's first dispatch, McpRevision validates that every registered tool's McpSchema uses only the six JSON Schema keywords its wire JsonSchema bean can represent (type, properties, required, additionalProperties, items, $defs). A schema using an unsupported keyword (oneOf, $ref, etc.) throws IllegalArgumentException, naming both the offending tool and the keyword, rather than silently dropping it on the wire.

Both the inputSchema and the outputSchema of each tool are checked, and each root schema must declare "type": "object" (the 2025-06-18 wire JsonSchema bean models only object schemas). The exception message names the tool, which role (inputSchema or outputSchema) failed, and either the non-object root type or the unsupported keyword. For example, a schema that contains oneOf fails with a message of the form Tool 'search' inputSchema declares JSON Schema keyword 'oneOf', which MCP revision 2025-06-18 cannot represent.

This check belongs to the 2025-06-18 adapter only. The 2026-07-28 adapter maps schemas through its unrestricted Draft 2020-12 JsonSchema<?> bean (see "Writing Handlers" above), so it does not reject these keywords. To stay portable across both revisions, restrict tool schemas to the six supported keywords. Schemas derived for McpTypedToolHandler on the 2025-06-18 adapter go through this same validation (see "Writing Handlers" above).

The check is memoized per McpServerConfig instance (an identity-keyed weak set), so it runs once — on that config's first routed request — not on every request. A server with an inexpressible schema still comes up healthy; only the first request against that config fails.

Polymorphic Wire Format​

MCP returns polymorphic content (Content, ResourceContents) discriminated by a type property. The core McpRestServlet enables addBeanTypes on its serializer via @SerializerConfig(addBeanTypes="true") so the discriminator is emitted on the wire — this carries over unchanged onto org.apache.juneau.rest.server.mcp.v20250618.McpRestServlet. If you build your own RestServlet (bypassing both), apply the same configuration.

See juneau-bean-mcp-v20250618 for the full wire-bean catalog.

Resource Templates, Reads, and Completions​

New in 10.0.0, cross-revision (both 2025-06-18 and 2026-07-28). Registered resource templates are no longer listing-only: a template can now be read through ordinary resources/read, and both prompt arguments and resource-template variables can be completed through completion/complete. Both features live entirely in the revision-neutral juneau-rest-server-mcp core; each dated adapter only adds wire mapping and dispatch on top of the same neutral model.

MCP defines no separate "read from template" method. A client discovers a template via resources/templates/list, expands it into a concrete URI itself, and sends that concrete URI through an ordinary resources/read. The server is responsible for resolving that concrete URI back to either an exact resource or a registered template.

Registering a readable template​

McpResourceTemplateHandler pairs the existing McpResourceTemplateSpec descriptor (uriTemplate, name, title, description, mimeType — unchanged) with a variable-aware read(...) method and an optional per-variable completer(...):

import java.util.*;

import org.apache.juneau.commons.inject.*;
import org.apache.juneau.rest.server.mcp.*;

public class FileTemplate implements McpResourceTemplateHandler {
@Override
public McpResourceTemplateSpec descriptor() {
return new McpResourceTemplateSpec()
.setUriTemplate("file:///{name}")
.setName("files")
.setMimeType("text/plain");
}

@Override
public McpResourceOutcome read(String uri, Map<String,String> variables, BeanStore ctx) {
String name = variables.get("name");
String content = loadFile(name);
// return null if this template does not actually serve `uri` (reported as resource-not-found)
if (content == null)
return null;
return new McpResourceOutcome().setContents(List.of(McpResourceContents.text(uri, "text/plain", content)));
}
}

config.addResourceTemplate(new FileTemplate());

McpServerConfig's primary resource-template APIs are handler-based: getResourceTemplates(), setResourceTemplates(List<McpResourceTemplateHandler>), and addResourceTemplate(McpResourceTemplateHandler...). The pre-existing descriptor-only registration style from the 2026-07-28 cache-hints work below still works unchanged, as a listing-only convenience — it's now a thin wrapper around a built-in handler whose read(...) and completer(...) both return null:

config.addResourceTemplate(new McpResourceTemplateSpec()
.setUriTemplate("file:///{name}").setName("files").setMimeType("text/plain"));

Bulk replacement of listing-only descriptors uses setResourceTemplateSpecs(List<McpResourceTemplateSpec>) (a second erasure-conflicting setResourceTemplates(List<McpResourceTemplateSpec>) overload isn't possible in Java). read(...) returning null for a handler-based registration is reported as resource-not-found, exactly like a listing-only template being selected — a selected template's outcome never falls through to a different, less-specific template.

The complete registry is validated up front: addResourceTemplate(...)/setResourceTemplates(...)/setResourceTemplateSpecs(...) reject a null handler/descriptor, a null/blank uriTemplate, a malformed template, a duplicate variable name within one template, or an exact duplicate uriTemplate string — all with IllegalArgumentException naming the offending index and template, before any request is ever dispatched. Because getResourceTemplates() returns the same mutable list applications can hold onto, direct list mutation is re-validated the next time the registry is consumed, rather than silently propagating a corrupted registry into dispatch.

The reverse-matchable subset​

Juneau does not implement complete RFC 6570 reverse matching for every legal template form. It hand-rolls a dependency-free, reliable reverse matcher for one narrow, invertible subset — reads always match a concrete URI back to variables, never the other direction (Juneau has no URI-template expansion API at all: the client always sends the fully-expanded concrete URI on resources/read). Reverse-matchable forms:

FormExampleNotes
Simple scalar{var}Captures exactly one path segment; never crosses an unencoded /.
Reserved scalar{+var}May capture reserved characters, including an unencoded /.
Fragment scalar{#var}Like {+var}, with a required leading #.
Path segments{/var}, {/x,y}One slash-prefixed segment per declared variable, in order.
Label segments{.var}, {.x,y}One dot-prefixed segment per declared variable, in order.
Query start/continuation{?x,y}, {&x,y}Every declared key exactly once, in declaration order; values may be empty.

Everything else that's syntactically legal RFC 6570 — exploded ({tags*}), prefixed ({var:3}), matrix ({;x}) forms, multi-variable simple/reserved/fragment expressions ({x,y}), and expressions with ambiguous adjacency to a neighboring expression — registers successfully (so it's still listed and its variables are still completable) but is never selected for a template-backed read; a concrete URI that only such a template could describe reports plain resource-not-found. This boundary is intentionally narrower than a complete RFC 6570 engine: Juneau owns reliable inversion of a settled subset, not speculative interpretation of non-injective expansions.

Matching follows RFC 3986 percent-normalization, implemented fresh (not via StringUtils.urlEncode/urlDecode, which are form-urlencoded and would be wrong here): %HH triplets are hex-normalized, percent-encoded unreserved octets compare as their literal character, and an encoded %2F stays inside a {var} segment as data rather than acting as a path separator — a captured %2F is delivered to the handler already decoded to /. A raw, unencoded / remains a segment boundary. + is always a literal plus (never form-encoding's plus-as-space). A concrete URI with a malformed percent escape or invalid UTF-8 capture simply doesn't match.

file:///{a}/{b} and file:///{name} are deliberately distinguishable: the first matches file:///one/two with {a=one, b=two}; the second does not ({name} can't cross the unencoded /), though file:///{+name} would, capturing name=one/two.

Resolution order​

For each resources/read:

  1. Exact resources always win — McpResourceHandler string-equality lookup runs first, unconditionally, even if a more-literal template would also match.
  2. If no exact resource matches, every reverse-matchable template is evaluated, and the winner is selected by, in order: more literal URI octets outside {...} (after percent normalization) → fewer declared variables → earlier registration index. Operator type, capture width, and descriptor name/title are never tie-breakers.
  3. The winning handler receives the original concrete URI, an immutable, insertion-ordered map of decoded variables, and the same per-request BeanStore an exact resource handler would get.
  4. No candidate, or the winning handler's read(...) returning null, is resource-not-found — -32002 (the 2025-06-18 spec's dedicated missing-resource code) on 2025-06-18 and RESOURCE_NOT_FOUND (-32602) on 2026-07-28. A handler throwing is -32603 on both.

On 2026-07-28, a template-backed read applies the adapter's existing cache-hint precedence (resourceReadOverrides → resourcesRead → defaultHint) using the original concrete request URI — the same precedence and lookup key an exact-resource read already uses; C4 adds no new cache concept.

Completions (completion/complete)​

Both revisions dispatch completion/complete against the same two completion targets:

  • a prompt argument — attach an McpCompleter to an McpPromptArgument:

    import org.apache.juneau.rest.server.mcp.*;

    McpPromptArgument city = new McpPromptArgument()
    .setName("city")
    .setRequired(true)
    .setCompleter((request, ctx) -> McpCompletionResult.empty()
    .setValues(citiesStartingWith(request.getValue())));
  • a resource-template variable — override completer(String) on the McpResourceTemplateHandler:

    @Override
    public McpCompleter completer(String variableName) {
    return "name".equals(variableName)
    ? (request, ctx) -> McpCompletionResult.empty().setValues(matchingFileNames(request.getValue()))
    : null;
    }

McpCompleter is a single-method @FunctionalInterface: McpCompletionResult complete(McpCompletionRequest request, BeanStore ctx). A completion reference always names an exact declaration — the prompt name, or the registered uriTemplate string itself — never a concrete/expanded URI; dispatch looks the target up by exact string equality, not by reverse-matching. Completer behavior:

  • An unknown prompt/template, an undeclared argument/variable, or a known argument/variable with no completer attached all return a successful empty completion ({"completion":{"values":[]}}) without ever invoking a completer — this is normal protocol behavior, not an error.
  • A malformed request (missing/wrong-typed ref, argument, or context) is -32602.
  • A completer that throws, returns null, returns a result with a null values-list element, or a negative total is -32603.
  • At most 100 values are ever emitted; truncating past that forces hasMore=true. Order and duplicates are exactly whatever the completer returned — Juneau performs no ranking, filtering, deduplication, or additional pagination of its own. A completer is also free to return more than 100 candidate values already ranked by the application; only the first 100 reach the wire.

On 2026-07-28, completion/complete is deliberately nameless for SEP-2243's Mcp-Method/Mcp-Name header agreement: Mcp-Method is completion/complete and Mcp-Name is the empty string, because the actual target is nested under params.ref, not one of the top-level name/uri fields SEP-2243 routes on for tools/call/prompts/get/resources/read. The request's normal per-request _meta (protocolVersion, clientInfo, capabilities) is still validated exactly as for every other 2026-07-28 method.

A prompt argument's completer is server behavior only — it's never mapped onto the dated wire PromptArgument bean (name/description/required only, on both revisions).

Completions capability​

Both dated ServerCapabilities gain a nullable completions property, auto-derived alongside tools/prompts/resources: it's advertised (as an empty {} CompletionCapability marker) only when at least one prompt-argument completer or resource-template variable completer is currently registered — registering a template or prompt with no completer does not turn it on. As with the other auto-derived capability flags, an explicit ServerCapabilities override remains authoritative and is never merged with the auto-derived value — the servlet/mixin capabilities() hook on 2025-06-18, or McpOptions.setCapabilities(...) on 2026-07-28.

See juneau-bean-mcp-v20250618 for the 2025-06-18 completion wire-bean catalog (CompleteRequest, CompletionReference/PromptReference/ResourceTemplateReference, CompletionArgument, CompletionContext, CompleteResult, Completion, CompletionCapability) and the equivalent 2026-07-28 family.

Configuring the endpoint: McpOptions (MCP 2026-07-28)​

v2-only. McpOptions (org.apache.juneau.rest.server.mcp.v20260728) is the single behavior-configuration aggregate for a 2026-07-28 binding — "how" the endpoint behaves, alongside the neutral McpServerConfig's "what" it exposes (tools/prompts/resources). It consolidates six previously-separate hooks (capabilities, instructions, cache, mrtr, subscriptions, subscriptionBroker) into one mutable-setup fluent bean, supplied by a single override on each path:

// Servlet-subclass path
@Rest(path="/mcp")
public class MyMcpServlet extends McpRestServlet {
@Override protected McpServerConfig createMcpConfig() {
return new McpServerConfig().setName("my-server").addTool(new MyEchoTool());
}
@Override protected McpOptions createMcpOptions() { // optional
return new McpOptions()
.setInstructions("Use tool 'echo' to test.")
.cache(c -> c.setToolsList(new McpCacheHint().setTtlMs(60_000)))
.subscriptions(s -> s.setQueueSize(2048));
}
}

// Mixin path (incl. Spring via SpringMcpRestServlet)
@Rest(path="/api")
public class MyResource extends BasicRestServlet implements McpEndpoint {
@Override public McpServerConfig getMcpConfig() { return new McpServerConfig().addTool(new MyEchoTool()); }
@Override public McpOptions getMcpOptions() { return new McpOptions().mrtr(m -> m.setTtlMs(600_000)); }
}

Both getCapabilities()/getInstructions() are plain getXxx()/setXxx(...) properties. The three nested configs (cache → McpCacheConfig, mrtr → McpMrtrConfig, subscriptions → McpSubscriptionsConfig) are framework-owned instances: use the cache(Consumer<McpCacheConfig>)/mrtr(Consumer<McpMrtrConfig>)/subscriptions(Consumer<McpSubscriptionsConfig>) configure-blocks to mutate them in place rather than constructing (and needing to remember to keep stable) a replacement instance yourself.

McpOptions is memoized exactly once per binding — the framework invokes createMcpOptions()/getMcpOptions() once and publishes the single result as an internal @Bean. This memoization matters because two of the six concepts are stateful: mrtr holds a random AES key (a sealed requestState can't be unsealed under a different key) and the subscription broker holds the live per-connection registry (a fresh one would silently split publishers from subscribers). Every binding gets its own per-instance mrtr key and broker — there is no accidental sharing across separate bindings.

This internal @Bean publication is authoritative and takes precedence over any Spring @Bean McpOptions in the application context — publishing a bare @Bean McpOptions in a Spring context does not get picked up in place of it. Spring users who want their configuration honored must inject their bean into the createMcpOptions()/getMcpOptions() override instead (for example, an @Autowired field read from there), as shown above.

getSubscriptionBroker() defaults to null, meaning "framework-derived": the binding lazily derives a BasicMcpSubscriptionBroker sized from subscriptions' queueSize the first time one is needed, and memoizes that single derived instance for the binding's lifetime. Call setSubscriptionBroker(...) to supply a custom broker instead (for example, one deliberately shared across multiple bindings).

OAuth 2.1 resource server (MCP 2026-07-28)​

v2-only. McpResourceServerConfig (org.apache.juneau.rest.server.mcp.v20260728) turns a 2026-07-28 MCP endpoint into an OAuth 2.1 protected resource server. It's one of the nested configs on McpOptions — mutate it in place via the resourceServer(Consumer<McpResourceServerConfig>) configure-block. For a copy-pasteable end-to-end (server + client) walkthrough, see the OAuth 2.1 recipe.

Off by default. isEnabled() is false unless setEnabled(true), so an endpoint that doesn't opt in behaves exactly as before — no bearer requirement, and the well-known route returns 404. When enabled, validateEnabled() requires a resource identifier (an absolute URI with scheme + authority) and a TokenValidator, or the binding fails.

Configuration surface​

@Override
public McpOptions getMcpOptions() {
var validator = JwtTokenValidator.create()
.jwksUrl(URI.create("https://login.example.com/realms/api/protocol/openid-connect/certs"))
.issuer("https://login.example.com/realms/api")
.audience("https://api.example.com/mcp")
.build();
return new McpOptions().resourceServer(rs -> rs
.setEnabled(true)
.setResource(URI.create("https://api.example.com/mcp"))
.setTokenValidator(validator)
.addAuthorizationServer(URI.create("https://login.example.com/realms/api"))
.addScopeSupported("mcp.read")
.addRequiredScope("mcp.read")
.setRealm("mcp"));
}
SettingMeaning
setEnabled(boolean)Master switch. Default false.
setResource(URI)The canonical RFC 9728 resource identifier and (unless overridden) the RFC 8707 audience. Required when enabled.
setTokenValidator(TokenValidator)Selects the validation mode. Required when enabled. OAuthIntrospectionValidator (juneau-rest-server-auth-oauth, RFC 7662 introspection of opaque tokens) or JwtTokenValidator (juneau-rest-server-auth-jwt, JWKS/issuer JWT verification).
addAuthorizationServer(URI)Advertised authorization_servers in the PRM document.
addScopeSupported(String) / addRequiredScope(String)scopes_supported advertisement, and the baseline scopes a token must carry (a required scope is also advertised as supported).
addOperationScope(String method, String name, String... scopes)SEP-2350 per-operation step-up scopes (see below). A blank/null name registers a method-wide entry; a specific name wins over it. Each scope is also advertised as supported.
setOperationScopeResolver(McpOperationScopeResolver)Overrides the static per-operation map with a dynamic resolver (seam for argument-derived scopes; not required for 10.0).
setAudience(String)Explicit RFC 8707 audience, overriding the resource-identifier default.
setRequireAudienceClaim(boolean)Default true: a validated principal exposing no audience claims is rejected (401 invalid_token). Set false only when the validator enforces aud internally (e.g. a JwtTokenValidator with audience(...), or an introspection validator that legitimately omits aud).
setRealm(String)The WWW-Authenticate: Bearer realm="…" challenge realm. Default mcp.
getBearerMethodsSupported()Advertised bearer_methods_supported. Defaults to [header].

Runtime behavior​

  • PRM discovery. Both bindings register unauthenticated @RestGet routes at /.well-known/oauth-protected-resource (SEP-2351 root fallback) and /.well-known/oauth-protected-resource/* (RFC 9728 path-inserted, matched against this resource's expected path). They serve the RFC 9728 document built from the config, or 404 when RS auth is disabled.
  • Bearer gate + challenges. Bearer extraction and token validation are delegated to the reusable OAuthFilter (RFC 6750) from juneau-rest-server-auth-oauth, memoized once per config. A missing token → 401 + WWW-Authenticate: Bearer realm="…", resource_metadata="…"; an invalid/expired token or audience mismatch → 401 + error="invalid_token"; a token missing a required scope → 403 + error="insufficient_scope", scope="…". On success the authenticated principal is stashed under the framework-standard principal attribute so @Auth Principal resolves in downstream handlers. The gate treats a POST claim on any override channel (?method=, or any of X-Method, X-HTTP-Method-Override, X-HTTP-Method, X-Method-Override; any value) as a POST, so an override can't slip past it (an authenticated GET ?method=POST is then refused by the router with 405; on the mixin path, where the gate runs after routing, an unauthenticated one gets 405 rather than 401), and (on the mixin path) is scoped to the resolved MCP dispatch operation so other POST operations on the host resource are unaffected. The well-known GET routes are intentionally left unauthenticated for discovery.
  • Audience enforcement (RFC 8707). The validated principal's audience claims must include the configured audience; see setRequireAudienceClaim(...) above for the claims-less-principal policy.
Origin-root mount requirement

RS auth requires the MCP endpoint to be mounted at the origin root — no context path, no non-root servlet path, and no @Mixin(path=...) re-mount prefix. RFC 9728 requires the PRM document at an origin-root well-known location, which is exactly the URL advertised in the resource_metadata challenge; Juneau registers the well-known routes servlet-relative, so any non-root mount would advertise a URL that resolves elsewhere and 404s. Rather than advertise an unreachable document, RS auth fails fast with a 500 carrying an origin-root diagnostic — on both the POST gate and the well-known route, so they never disagree. Mount a dedicated McpRestServlet at /*, or implement McpEndpoint on a root-mounted host with no @Mixin(path=...) prefix. A configurable external PRM URL that would relax this constraint is a deferred future enhancement, not a current feature.

Effectively immutable once published — same mutable-setup idiom as McpMrtrConfig/McpCacheConfig: configure it fully via the createMcpOptions() / getMcpOptions() override, then never mutate it afterward.

Per-operation step-up scopes (SEP-2350)​

Beyond the endpoint-wide baseline (addRequiredScope), you can require additional scopes for specific operations — e.g. a delete_repo tool that needs repo.delete on top of the baseline mcp.read:

return new McpOptions().resourceServer(rs -> rs
.setEnabled(true)
.setResource(URI.create("https://api.example.com/mcp"))
.setTokenValidator(validator)
.addRequiredScope("mcp.read") // baseline for every call
.addOperationScope("tools/call", "delete_repo", "repo.delete") // extra for one tool
);

Because the required MCP method (tools/call vs resources/read) — and thus the operation identity — isn't known until the JSON-RPC body is parsed, this enforcement runs at a POST-parse point in the dispatch layer, not at the pre-parse bearer gate. A caller whose token satisfies the baseline but not the operation's scopes gets a scoped step-up challenge:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer realm="mcp", error="insufficient_scope", scope="repo.delete", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"

All of the operation's required scopes are emitted in a single scope= param (the SEP-2350 "single challenge" SHOULD). Sufficiency is hierarchy-aware per the server MUST: a broader granted scope implies its narrower children (granted repo satisfies required repo.delete / repo:read, but granted repo.read does not satisfy required repo). Operations with no configured per-operation scope are unaffected — the baseline alone governs them. The client half of the flow (union the challenged scopes onto the prior grant and re-authorize) is in juneau-rest-client-mcp-auth. The static map is sufficient for 10.0; setOperationScopeResolver(...) is the seam for argument-derived dynamic scopes.

See it wired end to end. SecuredExampleMcpServer in the secured/ package of juneau-examples-mcp applies exactly this pattern with one createMcpOptions() override: a baseline addRequiredScope("mcp.read") plus addOperationScope("tools/call", "publishNote", "mcp.write") (and deleteNote) for the step-up, running fully offline against a demo authorization server.

Cache Hints (MCP 2026-07-28, SEP-2549)​

The 2026-07-28 adapter (org.apache.juneau.rest.server.mcp.v20260728) adds two capabilities on top of the neutral core: SEP-2549 cache hints on five list/read results, and a resources/templates/list endpoint. Both are configured statically, at server-construction time — there is no dynamic/per-request cache callback, and neither concept exists on the neutral juneau-rest-server-mcp core or the 2025-06-18 adapter.

Registering resource templates​

Resource templates are registered on the same neutral McpServerConfig used for tools/prompts/resources. addResourceTemplate(McpResourceTemplateSpec) (shown below) registers a listing-only template — descriptor only, no resources/read support — via the revision-neutral McpResourceTemplateSpec descriptor (uriTemplate, name, title, description, mimeType). Registering at least one resource or resource template auto-derives the resources capability, exactly like resources do today. See Resource Templates, Reads, and Completions above for making a template actually readable (McpResourceTemplateHandler) and completable (McpCompleter) — those APIs are cross-revision and apply equally to 2025-06-18.

Configuring cache hints​

McpCacheConfig carries one McpCacheHint (nullable ttlMs + McpCacheScope) per list method, plus a resourcesRead default and an exact-URI override map for individual resource reads. It's one of the nested configs on McpOptions — mutate it in place via the cache(Consumer<McpCacheConfig>) configure-block on the servlet's createMcpOptions() (or the mixin's getMcpOptions()):

@Override protected McpServerConfig createMcpConfig() {
return new McpServerConfig().addResourceTemplate(new McpResourceTemplateSpec()
.setUriTemplate("file:///{name}").setName("files").setMimeType("text/plain"));
}

@Override protected McpOptions createMcpOptions() {
return new McpOptions().cache(c -> c
.setDefaultHint(new McpCacheHint().setTtlMs(60_000))
.setToolsList(new McpCacheHint().setTtlMs(0))
.setResourcesRead(new McpCacheHint().setCacheScope(McpCacheScope.PUBLIC))
.addResourceReadOverride("file:///private",
new McpCacheHint().setTtlMs(5_000).setCacheScope(McpCacheScope.PRIVATE)));
}

The McpEndpoint mixin path reads the same shape from getMcpOptions():

@Override public McpOptions getMcpOptions() {
return new McpOptions().cache(c -> c.setToolsList(new McpCacheHint().setTtlMs(0)));
}

McpOptions is memoized once per binding (see Configuring the endpoint: McpOptions above), so cache behaves as effectively-immutable after construction, exactly as before.

Precedence is atomic — a matched hint's ttlMs/cacheScope pair is applied as a whole, never merged field-by-field with a lower-precedence hint:

  • List methods (tools/list, prompts/list, resources/list, resources/templates/list): the method-specific hint (e.g. toolsList) if set, else defaultHint if set, else no cache fields are written at all. An explicitly-set empty hint (both fields null) at a higher precedence still suppresses the lower one — it does not fall through.
  • resources/read: resourceReadOverrides.get(uri) (exact string match — no URI normalization or template expansion) if present, else resourcesRead if set, else defaultHint if set, else no cache fields.

Every page of a paginated list result carries an identical cache hint (or an identical absence of one) — cache hints describe the whole result, not a side channel keyed by page.

Registering resource templates through the neutral McpServerConfig/McpResourceTemplateSpec involves no cache-related types at all; cache policy is entirely adapter-owned, keeping the neutral core (and hence any future MCP revision built on it) free of both cache and dated-revision knowledge.

See juneau-bean-mcp-v20260728 for the wire-bean side of cache hints (McpCacheScope, CacheableResult<T>) and resource templates (ResourceTemplate, ListResourceTemplatesResult).

Trace-Context Propagation (MCP 2026-07-28)​

v2-only. Every 2026-07-28 MCP request participates in distributed tracing exactly the way any other @RestOp invocation does — there is no MCP-specific wiring to turn on. This section covers the MCP-specific carrier: how params._meta interacts with HTTP trace-context headers, and how the result echoes the active trace back to the caller. For the framework-level seam (TraceContextExtractor, TraceContextCarrier, TraceOperation, TracerHook, the OpenTelemetry bridge), see Observability — Micrometer + OpenTelemetry.

params._meta as a second trace-context carrier​

Per the 2026-07-28 schema, params._meta may carry the bare (unprefixed) W3C fields traceparent, tracestate, and baggage alongside the io.modelcontextprotocol/-prefixed negotiation keys:

{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "echo",
"arguments": {"text": "hi"},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "my-client", "version": "1.0"},
"io.modelcontextprotocol/clientCapabilities": {},
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"
}
}
}

Both McpRestServlet and McpEndpoint publish this revision's stable TraceContextExtractor (a @Bean mcpTraceContextExtractor()), which — only when an active TracerHook is registered — recognizes the resolved JsonRpcRequest argument before the span opens and builds a composite carrier over params._meta:

lookup(key) = params._meta[key] if present, otherwise the HTTP header of the same name

An explicit params._meta value wins over the equivalent HTTP header — independently for traceparent, tracestate, and baggage. An absent metadata key simply falls back to the HTTP header, letting an MCP client propagate its own trace context even when the transport-level HTTP headers belong to some other hop (a gateway, a load balancer, etc.). With no tracer registered, this extraction never runs at all — the same zero-allocation no-tracer fast path described on the observability page.

Result _meta echo​

Before returning any successful result, the adapter copies the active trace context — whatever the TracerHook bridge rendered at span-start, whether that came from params._meta or the HTTP fallback — into the result's own _meta, alongside the server identity every success already carries:

{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"_meta": {
"io.modelcontextprotocol/serverInfo": {"name": "my-server", "version": "1.0.0"},
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-<server-span-id>-01"
},
"content": [{"type": "text", "text": "hi"}]
}
}

The same values also reach the ordinary HTTP traceparent/tracestate response headers (see the observability page) — an MCP client and an HTTP-level observer read the identical trace identifiers, whichever surface they look at. tracestate/baggage are only added when non-empty. JSON-RPC errors have no result, so there is no error-response echo — see below for how an error is still observed on the span.

Span naming, attributes, and JSON-RPC errors​

When the OpenTelemetry bridge (juneau-rest-server-tracing-otel) is active as the TracerHook, it enriches the span with pinned, experimental gen_ai.*/mcp.* attributes derived from the request, and names the span after the MCP operation rather than the bare HTTP method:

MethodSpan nameNotable attributes
tools/call (tool echo)"tools/call echo"gen_ai.tool.name=echo, gen_ai.operation.name=execute_tool
prompts/get (prompt greet)"prompts/get greet"gen_ai.prompt.name=greet
resources/read"resources/read" (the URI stays an attribute, never a span-name suffix)mcp.resource.uri=<uri>
everything elsethe exact method, e.g. "tools/list"—

Every span additionally carries mcp.method.name, mcp.protocol.version, and (when present) jsonrpc.request.id.

Because a dated MCP adapter always answers over HTTP 200 — even for a JSON-RPC-level error like an unknown tool — the HTTP status alone can never mark a span failed. Before returning a JSON-RPC error response, McpRevision calls a neutral Scope.recordRpcError(code, message) on the request's active tracing scope; the OpenTelemetry bridge maps that into rpc.response.status_code, a low-cardinality error.type category, and StatusCode.ERROR with the JSON-RPC message as the status description. A handler exception still goes through the ordinary thrown-exception path (Span.recordException(...) + exception.type).

All of the above attribute names and span-naming choices are pinned to a specific, experimental OpenTelemetry GenAI semantic-conventions snapshot — see Observability — Micrometer + OpenTelemetry for the exact pin, the full attribute reference, and the neutral seam these classes are built on.

Multi-Round-Trip Requests (MCP 2026-07-28, SEP-2322)​

v2-only. The 2026-07-28 adapter (org.apache.juneau.rest.server.mcp.v20260728) adds server-side support for SEP-2322 Multi-Round-Trip Requests: a handler for tools/call, prompts/get, or the exact-resource resources/read path can pause to ask the caller for more input, returning an InputRequiredResult; the caller resumes by re-sending the same request with its answers plus the echoed requestState. This mechanism is a synchronous request/response pause-and-resubmit loop over ordinary HTTP: each round is a normal JSON-RPC call/response pair. It is not the server-to-client SSE duplex seam — no streaming, no server-initiated push, no long-lived channel is involved at any point. Neither the neutral juneau-rest-server-mcp core nor the 2025-06-18 adapter has any such Multi-Round-Trip concept; it lives entirely on the v2 adapter, exactly as cache hints do.

The resource-template read path, completion/complete, and the list/discover/ping paths are not MRTR-wired — a pause signal thrown from one of them is not caught and surfaces as the -32603 internal-error fail-safe, by design.

Pausing from a handler​

A handler signals a pause by throwing McpInputRequiredSignal from its ordinary call/get/read body — a v2-only unchecked exception, not an McpException (it becomes a successful input_required result, not a JSON-RPC error). This throw-to-pause mechanism is a v2-adapter addition; the Phase 0 design deliberately kept it off the neutral core, so no revision-neutral type changed:

import org.apache.juneau.rest.server.mcp.v20260728.*;

// inside McpToolHandler.call(...)
throw new McpInputRequiredSignal(
Map.of("q1", Map.of("type", "elicitation")), // inputRequests: at least one, free-form sub-requests
"my-continuation"); // opaque, handler-owned continuation (may be null)

The dispatcher catches the signal, seals the continuation (plus the method, a 1-based round counter, and an expiry) into an opaque requestState, and returns an InputRequiredResult carrying the handler's inputRequests map and that requestState.

Resuming: the two BeanStore-injected handler contexts​

On a resume call the same handler runs again; two immutable beans in the per-request BeanStore let it detect and react to the round:

  • McpMrtrResumeContext — stashed only on a resume call. Carries continuation() (the decoded value, as generic JSON — use continuationAs(Class) to recover a typed view) and inputResponses() (the caller's answers). A handler detects resume via ctx.getBean(McpMrtrResumeContext.class) returning a present Optional; absent means a first-round call. For the common case of a String continuation, continuationAsString() is a shorthand for continuationAs(String.class).
  • McpMrtrCapabilityContext — stashed on every in-scope call (first-round and resume), exposing elicitationSupported() so a handler can pre-check and degrade gracefully (return a normal result or a domain error) instead of throwing a pause into a client that can't handle it.

Only the sealed continuation is tamper-evident across rounds; the per-round arguments are re-sent by the client and fully client-controlled, so anything a handler must trust across rounds belongs inside the continuation, never re-read from the arguments.

Sealing requestState: the codec SPI​

RequestStateCodec is the pluggable seal/unseal SPI (mirroring the client-side McpResponseCache "SPI + built-in default" shape). The built-in default, AeadRequestStateCodec, is AES-256-GCM; the key material it seals/unseals under is itself pluggable via a KeyProvider (see below) — the codec is AEAD-specific, KeyProvider is the layer that varies by deployment. The canonical AAD binding each token to its originating request is method + '\u0000' + protocolVersion (NUL-separated); the codec appends '\u0000' + keyId beneath that. A requestState is a bearer token — replayable any number of times within its TTL by default, carrying no caller identity — so it must travel only over authenticated TLS and must never be logged. Wiring a ReplayCache narrows that default to single-use.

The sealed-token wire format is four dot-joined segments: version . b64url(keyId) . b64url(nonce) . b64url(ciphertext+tag). keyId travels in the clear (it must be read before decryption can select the key) but is folded into the AEAD's authenticated data, so a swapped keyId fails the integrity check exactly like a tampered ciphertext.

Key management: KeyProvider​

AeadRequestStateCodec resolves the AES key it seals/unseals under from a KeyProvider:

public interface KeyProvider {
KeyedSecret currentKey(); // the key to seal new tokens with, plus its keyId
Optional<SecretKey> resolveKey(String keyId); // resolve an incoming token's keyId; empty if unknown/retired
}

Two built-ins, both in org.apache.juneau.rest.server.mcp.v20260728:

  • EphemeralKeyProvider — the zero-config default beneath new AeadRequestStateCodec(): a random AES-256 key and a random keyId, generated once per instance. Deliberately not restart-durable and not shareable across process instances — a token sealed by one instance can never be unsealed by another, or by the same process after a restart.
  • StaticKeyProvider — an immutable, statically-configured keyring for the shared/rotating-key case: StaticKeyProvider.of(keyId, key) for a single shared key, or the builder (StaticKeyProvider.create().addKey(...).current(...).build()) to keep an older key resolvable while sealing new tokens under a newer one during a rollover. StaticKeyProvider.aesKey(byte[]) / aesKey(String base64) build the SecretKey from raw key material.

Supply either via the McpMrtrConfig.setKeyProvider(KeyProvider) convenience — sugar for setCodec(new AeadRequestStateCodec(keyProvider)) — or implement KeyProvider directly against a real secrets manager (see the MRTR key management recipes for both patterns). setKeyProvider(...) and setCodec(...) are last-wins (both assign the same underlying codec field); there is no getter for the provider itself — getCodec() remains the sole accessor once either has been called.

Configuring MRTR: McpMrtrConfig​

McpMrtrConfig is the adapter-owned configuration bean, placed exactly like McpCacheConfig (v2-adapter-only, never on the neutral core). It carries the RequestStateCodec, a requestState TTL (default 5 minutes), and a max-rounds cap (default 10). It's one of the nested configs on McpOptions — mutate it in place via the mrtr(Consumer<McpMrtrConfig>) configure-block:

@Override
protected McpOptions createMcpOptions() {
return new McpOptions().mrtr(m -> m
.setKeyProvider(StaticKeyProvider.of("2026-08-a", StaticKeyProvider.aesKey(sharedSecretBytes))) // or leave unset for the ephemeral default
.setTtlMs(5 * 60 * 1000L)
.setMaxRounds(10));
}

McpOptions (and hence mrtr) is memoized once per binding and treated as read-only afterward. With the ephemeral default, each AeadRequestStateCodec holds a distinct random key, so exactly one instance must be published per binding — the mixin path gets its own per-binding key too, with no sharing across separate endpoint instances. Supplying a shared KeyProvider via setKeyProvider(...) is the supported way to opt back into cross-binding/cross-instance sharing for a load-balanced deployment.

Replay protection: ReplayCache (opt-in)​

By default a sealed requestState keeps the behavior described above: it's a multi-use bearer token, replayable any number of times within its TTL, so handlers must keep resume side effects idempotent — that default is unchanged. To narrow it to single-use, wire a ReplayCache:

public interface ReplayCache {
boolean checkAndRecord(String jti, long expiresAtMs);
}
new McpOptions().mrtr(m -> m.setReplayCache(new InMemoryReplayCache()));

checkAndRecord is a single atomic check-and-record call — not a separate seen/record pair — so two near-simultaneous submissions of the same token can't both observe "not yet seen" and both proceed. expiresAtMs (the token's own sealed expiry) is passed through so an implementation can self-evict without a separate cache-wide TTL setting.

  • InMemoryReplayCache — the built-in implementation: a per-process, seen-jti set that self-evicts once a record's own expiresAtMs passes. Like EphemeralKeyProvider, it is not shareable across process instances — two nodes behind a load balancer each enforce single-use independently, so a token resumed against one node and then replayed against the other is not caught. Cross-node single-use requires an operator-supplied ReplayCache backed by a store shared across every node (for example Redis) — the same scaling story as KeyProvider/StaticKeyProvider.
  • Fail-open on throw; fail-closed only if you return false. If a wired ReplayCache.checkAndRecord throws, the dispatcher logs it and treats the token as first-seen — a transient store outage degrades to the default multi-use-tolerant behavior instead of rejecting all MRTR resume traffic. An operator who wants fail-closed instead must catch their own store failures internally and return false (a "replay" outcome) rather than let the exception propagate; the framework applies no fail-closed policy of its own and offers no config toggle for one.
  • A detected replay is rejected with CODE_REQUEST_STATE_REPLAYED (-32025 — see Error codes below), only ever thrown when a ReplayCache is configured.

Argument-hash sealing (always-on)​

Unlike replay protection, this check needs no configuration — it's always on. The original tools/call/prompts/get request's arguments are canonicalized (RFC 8785 JSON Canonicalization Scheme) and SHA-256-hashed at PAUSE time; the same hash is recomputed from the RESUME request's own arguments and compared. A well-behaved client that re-sends the exact same arguments it originally sent — the documented, expected case — sees no change in behavior.

A resume that changes the arguments is rejected with CODE_REQUEST_STATE_ARGUMENTS_MISMATCH (-32026), closing the previously-documented gap where a client was merely expected to re-send its original arguments faithfully, with nothing enforcing it. resources/read's exact-path branch takes no arguments at all and is unaffected — it hashes the canonical empty object at both PAUSE and RESUME, so the two always agree.

Error codes​

Alongside the standard JSON-RPC codes, MRTR dispatch adds five v2-only codes (constants on org.apache.juneau.rest.server.mcp.v20260728.McpRevision):

CodeConstantCondition
-32021CODE_MISSING_REQUIRED_CLIENT_CAPABILITYA handler tried to pause, but the request's _meta.clientCapabilities did not advertise elicitation. Gating reuses the existing elicitation client capability — there is no dedicated input_required capability flag.
-32022CODE_REQUEST_STATE_EXPIREDThe echoed requestState unsealed successfully but its sealed expiry has passed.
-32023CODE_MAX_ROUNDS_EXCEEDEDThe resume chain exceeded the configured max-rounds cap.
-32025CODE_REQUEST_STATE_REPLAYEDA configured ReplayCache reports the echoed requestState has already been consumed once before. Only thrown when a ReplayCache is wired — replay rejection is opt-in.
-32026CODE_REQUEST_STATE_ARGUMENTS_MISMATCHThe resume request's arguments don't hash to the same value sealed at PAUSE time. Always checked.

A requestState that fails to unseal — tampered, truncated, sealed for a different method, or otherwise invalid — is the standard -32602 invalid-params code (the codec returns empty rather than throwing; the dispatcher maps that to -32602).

See juneau-bean-mcp-v20260728 for the wire-bean side (InputRequiredResult, the inputResponses/requestState request fields, and ElicitationCapability).

Elicitation (MCP 2026-07-28, SEP-2322)​

v2-only. Elicitation rides the Multi-Round-Trip Requests loop above with zero mechanism changes — no new dispatcher hook, no new capability gate (the existing elicitationSupported() gate was already elicitation-named, since C6 was its intended concrete consumer from the start), no new BeanStore bean type. Two static helper classes in org.apache.juneau.rest.server.mcp.v20260728 add typed convenience on top of the plain Map<String,Object> contract McpInputRequiredSignal/McpMrtrResumeContext already define:

HelperPurpose
ElicitationRequestsBuilds an McpInputRequiredSignal from one or more typed ElicitRequests, serializing each to the Map-shaped value the signal's constructor requires.
ElicitationResponsesParses typed ElicitResults back out of an McpMrtrResumeContext's inputResponses().
import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.rest.server.mcp.v20260728.*;

// inside McpToolHandler.call(...)
var resume = ctx.getBean(McpMrtrResumeContext.class);
if (resume.isEmpty()) {
var question = new ElicitRequest()
.setMessage("Proceed with deletion?")
.setRequestedSchema(ElicitSchema.create().booleanField("confirm").title("Confirm").build());
throw ElicitationRequests.of("confirm", question, "my-continuation");
}
var answer = ElicitationResponses.get(resume.get(), "confirm");
// answer.getAction() is ACCEPT / DECLINE / CANCEL; answer.getContent() is populated only on ACCEPT

Both helpers support the multi-question case identically: ElicitationRequests.of(Map<String,ElicitRequest>, continuation) places several questions in one round, and ElicitationResponses.all(resume.get()) returns every answer keyed the same way — no extra round trip is needed to ask (or answer) more than one question at once.

For the common case of reading a single typed field — a boolean confirm/deny or a string answer — ElicitationResponses.getBoolean(ctx, id, field) and getString(ctx, id, field) skip the get(...) + null-check + content-map dance: a missing id, a missing field, or a non-ACCEPT action (declined/cancelled) all resolve to a safe default (false for getBoolean, null for getString) rather than requiring the caller to null-check by hand. See the confirm-delete recipe for getBoolean in use.

See juneau-bean-mcp-v20260728 for ElicitRequest/ElicitResult/ElicitAction/ElicitSchema's wire shapes and the ElicitSchema builder API in full.

  • juneau-bean-mcp-v20250618 — The 2025-06-18 wire beans consumed by the adapter module, plus the revision-neutral JSON-RPC envelope beans (juneau-bean-jsonrpc) both modules build on.
  • juneau-rest-server — The base REST server framework.

Resources​