juneau-rest-client-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.
juneau-examples-mcp is a complete, copy-me Maven module pairing a 2026-07-28 MCP server with a guided McpClient walkthrough (ExampleClient) that exercises every client call covered by this reference — discovery, tool calls, resource/template reads, completions, prompts, subscriptions, and an elicitation round-trip. It ships as a downloadable project zip in the Apache release. See MCP Recipes for the full rundown.
juneau-rest-client-mcp is the revision-neutral MCP client core built on juneau-rest-client, with two dated adapters: juneau-rest-client-mcp-v20250618 and juneau-rest-client-mcp-v20260728. 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
The client stack is split exactly like the server stack:
juneau-rest-client-mcp- neutral transport/auth/cache/duplex seams centered onAbstractMcpClient.juneau-rest-client-mcp-v20250618- typed facade for MCP2025-06-18.juneau-rest-client-mcp-v20260728- typed facade for MCP2026-07-28.
The neutral core sends raw JSON-RPC (JsonRpcRequest/JsonRpcResponse) and leaves revision-specific _meta/header details to dated adapters.
Quickstart and version chooser
Pick the client class that matches the revision your server speaks. A client instance is bound to one revision; there is no runtime negotiation between the two.
2026-07-28 (current) | 2025-06-18 | |
|---|---|---|
| Artifact | juneau-rest-client-mcp-v20260728 | juneau-rest-client-mcp-v20250618 |
| Client class | org.apache.juneau.rest.client.mcp.v20260728.McpClient | org.apache.juneau.rest.client.mcp.v20250618.McpClient |
| Handshake | serverDiscover(), or McpClient.connect(...) which calls it for you | initialize() |
| Typed calls | listTools, callTool, callToolText, listPrompts, getPrompt, listResources, readResource, listResourceTemplates, complete, ping | listTools, callTool, listPrompts, getPrompt, listResources, readResource, listResourceTemplates, complete, ping |
| Elicitation auto-resume | yes (*WithElicitation) | no |
Subscriptions (listen) | yes | no |
| Response cache | yes (Builder.responseCache) | no |
| OAuth 2.1 add-on | yes (juneau-rest-client-mcp-auth) | no |
2026-07-28 quickstart. connect(endpoint) builds the client and performs the mandatory server/discover handshake in one call:
import java.util.*;
import org.apache.juneau.rest.client.mcp.v20260728.McpClient;
try (var client = McpClient.connect("http://localhost:8080/mcp")) {
System.out.println(client.listTools());
System.out.println(client.callToolText("echo", Map.of("text", "hello")));
}
To customize the client, build it from a Builder and pass that to connect(Builder). The inherited builder methods are endpoint(String), transport(HttpTransport), and interceptor(RestCallInterceptor); the 2026-07-28 builder adds clientCapabilities(ClientCapabilities), clientInfo(Implementation), traceFieldsSupplier(Supplier<Map<String,String>>) (W3C traceparent/tracestate/baggage stamped into every request's params._meta), and responseCache(McpResponseCache):
var client = McpClient.connect(McpClient.builder()
.endpoint("https://api.example.com/mcp")
.clientInfo(new Implementation().setName("my-app").setVersion("1.0"))
.interceptor(McpAuthInterceptor.ofStaticBearer(token)));
2025-06-18 quickstart. There is no connect(...); call initialize() before anything else:
import java.util.*;
import org.apache.juneau.rest.client.mcp.v20250618.McpClient;
try (var client = McpClient.builder().endpoint("http://localhost:8080/mcp").build()) {
client.initialize();
System.out.println(client.callTool("echo", Map.of("text", "hello")));
}
Both clients are Closeable. The transport is any HttpTransport from the NG REST client; if you don't call transport(...), one is auto-discovered. Non-success JSON-RPC responses are thrown as McpException.
SSE and duplex seam
The duplex loop uses the general-purpose SSE capability on juneau-rest-client:
RestRequest.openEventStream()ResponseBody.asEventStream()
Inbound server->client requests are dispatched through McpServerRequestHandler as raw JsonRpcRequest payloads; params remain generic JsonMap/Object and are not typed or rewritten in the seam.
The illustrative server-initiated method name used elsewhere in this codebase's Javadoc/tests for this seam is sampling/createMessage (see juneau-bean-mcp-v20260728) — elicitation, by contrast, resumes over the ordinary Multi-Round-Trip Requests HTTP re-call path below, never over this duplex channel.
Elicitation (MCP 2026-07-28, SEP-2322)
v2-only. A tools/call, prompts/get, or resources/read handler can pause with an input_required result to elicit more input (Multi-Round-Trip Requests). The client offers two ways to consume that loop: an ergonomic auto-resume helper for the common case, and a raw manual path as the escape hatch.
Auto-resume (recommended)
McpClient.callToolWithElicitation(...), getPromptWithElicitation(...), and readResourceWithElicitation(...) take an McpElicitationHandler and drive the whole loop for you: they detect each input_required pause, decode that round's requests, invoke your handler for the answers, echo them back with the carried requestState, and repeat until a terminal result — which they return already decoded into the same typed result bean (CallToolResult/GetPromptResult/ReadResourceResult) as the plain callTool/getPrompt/readResource methods.
import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.rest.client.mcp.v20260728.*;
// One call. The handler is invoked once per input_required pause and answers that round's
// requests (there may be more than one), keyed by the same server-assigned ids.
var result = client.callToolWithElicitation("confirm", Map.of(), requests -> {
var answers = new LinkedHashMap<String,ElicitResult>();
requests.forEach((id, req) -> // req.getMessage() is the prompt to show the end user
answers.put(id, new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true)));
return answers; // Map<String,ElicitResult>
});
- Multiple requests per round — a round's
requestsmap may hold more than one entry; the handler answers all of them and returns one keyed map. - Decline / cancel — return an
ElicitResultwithElicitAction.DECLINEorElicitAction.CANCELfor a request. Refusals are echoed back like any other answer (the server decides the terminal outcome of a refused elicitation); the client does not short-circuit locally. - Bounded — the loop is capped at
McpClient.DEFAULT_MAX_ELICITATION_ROUNDS(8) rounds; amaxRoundsoverload sets your own bound. A server that keeps pausing past the cap throwsMcpElicitationLimitException(withgetMaxRounds()) rather than looping forever.
Manual resume (escape hatch)
For full control — or to resume a call you issued through callRaw(...) yourself — two static helper classes in org.apache.juneau.rest.client.mcp.v20260728 work directly on callRaw's raw Map<String,Object> result:
| Helper | Purpose |
|---|---|
ElicitationRequests | Reads the server's input_required pause out of a raw result: isInputRequired, requests (Map<String, ElicitRequest>), requestState. |
ElicitationResponses | Encodes the client's answers back into a resume call: toInputResponse/toInputResponses. |
This mirrors the server-side ElicitationRequests/ElicitationResponses pair (see juneau-rest-server-mcp) — same class names, same direction-pair scheme, disambiguated by package: the client reads requests / writes responses, the server writes requests / reads responses.
import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.rest.client.mcp.v20260728.*;
var paused = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm").setArguments(Map.of()));
if (ElicitationRequests.isInputRequired(paused)) {
var questions = ElicitationRequests.requests(paused); // Map<String, ElicitRequest>
// ... show questions.get("confirm").getMessage() to the end user, collect an answer ...
var answer = new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true);
var completed = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm")
.setRequestState(ElicitationRequests.requestState(paused))
.setInputResponses(ElicitationResponses.toInputResponse("confirm", answer)));
}
Both helpers work at the raw map/JSON level rather than exposing typed overloads per concrete request bean, because CallToolRequest/GetPromptRequest/ReadResourceRequest share no common "has-inputResponses-and-requestState" interface — a caller resuming a specific one of the three makes its own final .setInputResponses(...).setRequestState(...) call on the concrete bean it already knows it holds. ElicitationResponses.toInputResponses(Map<String,ElicitResult>) encodes several answers into one round's resume payload for the multi-question case. The auto-resume helpers above are built on exactly these two classes.
See juneau-rest-server-mcp for the server-side helper half of this loop.
Subscriptions (MCP 2026-07-28)
v2-only. McpClient.listen(SubscriptionFilter, McpSubscriptionListener) sends subscriptions/listen and keeps the resulting event stream open, delivering change notifications to your listener until you close the returned McpSubscriptionHandle, the server completes the stream, or the connection drops. For the server side (emitting changes) see MCP Recipes and juneau-rest-server-mcp.
The filter is a SubscriptionFilter bean (package org.apache.juneau.bean.mcp.v20260728) with toolsListChanged, promptsListChanged, resourcesListChanged (all Boolean) and resourceSubscriptions (a List<String> of resource URIs). The listener's methods are all default no-ops, so override only what you need:
| Listener method | Called when |
|---|---|
onAcknowledged(SubscriptionFilter honoredFilter) | The server's mandatory first frame; carries the filter the server actually honored. |
onResourceUpdated(String uri) | A subscribed resource changed. |
onListChanged(McpListChangedKind kind) | The tool, prompt, or resource list changed; kind is TOOLS, PROMPTS, or RESOURCES. |
onComplete() | The server ended the stream gracefully with a terminal response. |
onError(Throwable t) | The server ended it with a JSON-RPC error (an McpException), a frame could not be decoded, or the stream closed before a terminal frame arrived. |
import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.rest.client.mcp.v20260728.*;
var filter = new SubscriptionFilter()
.setToolsListChanged(true)
.setResourceSubscriptions(List.of("file:///config.json"));
try (McpSubscriptionHandle handle = client.listen(filter, new McpSubscriptionListener() {
@Override public void onResourceUpdated(String uri) { System.out.println("updated: " + uri); }
@Override public void onListChanged(McpListChangedKind kind) { System.out.println("list changed: " + kind); }
@Override public void onError(Throwable t) { t.printStackTrace(); }
})) {
// ... keep the application running; callbacks arrive on a background thread ...
}
Behavior worth knowing:
- Only opening the stream is synchronous: a transport failure at that point is thrown from
listen(...)asIOException. Every later frame is decoded on a dedicated daemon thread and delivered to the listener; failures after that point arrive throughonError, never as a thrown exception. - Heartbeat
pingevents are skipped, and an unrecognized notification method is ignored rather than treated as an error, so a newer server cannot break an older client's subscription. - An exception thrown from one of your listener callbacks is contained; it does not tear down the subscription.
McpSubscriptionHandlehasid()(the client-generated JSON-RPC request id of the opening request),isOpen(),cancel(), andclose().cancel()andclose()are idempotent and equivalent.
Sampling and other server-initiated requests
MCP lets a server send requests to the client over the duplex channel, the best-known example being sampling/createMessage (McpMethods.SAMPLING_CREATE_MESSAGE). The client exposes this as a raw seam rather than a typed API: McpClient.setServerRequestHandler(McpServerRequestHandler) registers a handler that receives each inbound JsonRpcRequest, and whatever it returns is posted back to the server as the result. If no handler is registered, a request gets a -32601 error (a notification is silently dropped); if the handler throws an McpException that error is returned as-is, and any other exception becomes a -32603 error.
The params arrive as untyped generic JSON, so decode them into the typed sampling beans yourself. CreateMessageRequest, CreateMessageResult, and SamplingMessage live in org.apache.juneau.bean.mcp.v20260728 (see juneau-bean-mcp-v20260728):
import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.bean.jsonrpc.McpException;
import org.apache.juneau.marshall.marshaller.Json;
client.setServerRequestHandler((request, ctx) -> {
if (! McpMethods.SAMPLING_CREATE_MESSAGE.equals(request.getMethod()))
throw new McpException(-32601, "Unsupported server request: " + request.getMethod());
var typed = Json.to(Json.of(request.getParams()), CreateMessageRequest.class);
String reply = myModel.complete(typed); // your own LLM call
return new CreateMessageResult()
.setRole(Role.ASSISTANT)
.setContent(new TextContent().setText(reply))
.setModel("my-model")
.setStopReason("endTurn");
});
The client does not advertise sampling on your behalf: set it via Builder.clientCapabilities(new ClientCapabilities().setSampling(...)) if your server gates on it. Note that the method which reads one server message off the event stream and dispatches it to your handler is currently package-private in McpClient (it is a test and plumbing entry point), so there is no public pump for this seam yet; treat this section as describing the handler contract rather than a turnkey sampling client.
Auth seam
McpAuthInterceptor is the client-side auth seam. It injects Authorization: Bearer ... from a supplier callback at request-init time. OAuth/OIDC flows plug in through the supplier implementation.
McpAuthInterceptor is a RestCallInterceptor: on onInit(...) it reads the current token from its Supplier<String> and sets Authorization: Bearer <token>. A null token suppresses the header (the call proceeds unauthenticated); a supplier that throws aborts the call before it is sent. McpAuthInterceptor.ofStaticBearer(token) is the fixed-token shortcut. Attach it via the client builder's interceptor(...):
var client = McpClient.connect(McpClient.builder()
.endpoint("https://api.example.com/mcp")
.interceptor(McpAuthInterceptor.ofStaticBearer("eyJ…")));
OAuth 2.1 authorization (juneau-rest-client-mcp-auth)
v2-only. The juneau-rest-client-mcp-auth module supplies the OAuth 2.1 token-acquisition machinery that feeds the auth seam above. It is an add-on to the client stack (it depends on the Nimbus OAuth 2.0 SDK); nothing here is on the classpath unless you add it. For a copy-pasteable end-to-end walkthrough see the OAuth 2.1 recipe.
McpTokenProvider
A thread-safe headless Supplier<String> that acquires, caches, and refreshes a bearer token. Three modes:
- Static —
McpTokenProvider.ofStaticToken(token)always returns a fixed, non-blank token (issues no token request, takes no resource indicator). - Client-credentials —
McpTokenProvider.clientCredentials()(RFC 6749 §4.4); re-acquired on expiry. - Refresh-token —
McpTokenProvider.refreshToken(initialRefreshToken)(RFC 6749 §6, SEP-2207); a rotated refresh token returned by the IdP is captured for the next refresh with no re-consent, andcurrentRefreshToken()exposes the most-recently-rotated value so callers can persist it across restarts.
Both dynamic modes are builder-configured (tokenEndpoint, clientId, clientSecret/clientSecretSupplier, scope, expirySkew, httpTimeout, httpRequestConfigurator) and require resource(URI) — the RFC 8707 resource indicator carried on every token request, binding the token's audience to the target MCP server (build() throws without it). get() never returns a blank token; a terminal invalid_grant in refresh mode latches the provider so every later get() throws a distinguishable McpAuthException instead of re-hammering the IdP. toString() redacts token/secret material. interceptor() returns an McpAuthInterceptor backed by this provider, ready to attach to a client builder.
McpAuthorizationCodeAcquirer
Interactive authorization-code + PKCE (S256) acquisition for CLI/native use, built via McpAuthorizationCodeAcquirer.create(). acquire(Duration) opens a LoopbackRedirectReceiver, generates the state and PKCE verifier, hands the authorization URL to the browserLauncher (default: prints the URL), waits for the loopback redirect, validates it, and exchanges the code for an OAuthToken. Builder essentials:
authorizationEndpoint,tokenEndpoint,clientId— required;clientSecretoptional (omit for a public PKCE-only client).resource(URI)— required (RFC 8707 audience binding).expectedIssuer(URI)— required by default (SEP-2468iss/ RFC 9207 check, gating both success and error callbacks); opt out withskipIssuerValidation(true), or tighten withrequireIssuerResponseParameter(true)when the AS advertisesauthorization_response_iss_parameter_supported.offlineAccess(true)— adds theoffline_accessscope so the IdP issues a refresh token (feed it to a refresh-modeMcpTokenProvider).browserLauncher(Consumer<URI>),redirectPath,scope,store,httpTimeout,httpRequestConfigurator.
Static helpers validateAuthorizationResponse(...) perform the pure SEP-2468 / CSRF validation step for callers driving their own user-agent interaction.
LoopbackRedirectReceiver
A minimal AutoCloseable loopback (http://127.0.0.1:<ephemeral>) HTTP listener that captures the single authorization-code redirect (SEP-837). redirectUri() is the URI to register with the IdP; awaitCallback(Duration) blocks for the redirect and returns the full callback URI (or throws McpAuthException on timeout). McpAuthorizationCodeAcquirer uses it internally; it's public for callers driving the flow manually.
McpProtectedResourceMetadataClient + WwwAuthenticateChallenge
McpProtectedResourceMetadataClient fetches and parses an RFC 9728 PRM document and resolves the authorization server a protected MCP resource delegates to. fetch(prmUrl) / parse(json, source) return an McpProtectedResourceMetadata (resource(), authorizationServers(), firstAuthorizationServer(), scopesSupported(), extras()). When expectedResource(URI) is set (or passed per-call), the document's resource field is validated against it (RFC 9728 §3.3 exact-match) — defending against a malicious server pointing you at a PRM minted for a different resource. discoverAuthorizationServer(prm) selects the first advertised AS and validates its issuer via OidcDiscoveryClient. By default the PRM URL and discovered AS URL must be https (loopback always allowed); allowInsecureHttp(true) disables that.
WwwAuthenticateChallenge.parse(headerValue) parses a WWW-Authenticate: Bearer … challenge (RFC 6750 §3 / RFC 9728 §5.1); resourceMetadata() recovers the PRM pointer, scopes() the challenged step-up scopes, error() the error code.
OidcDiscoveryClient
Fetches an authorization server's metadata as an OidcMetadata record (issuer, tokenEndpoint, authorizationEndpoint, introspectionEndpoint, jwksUri, supportedScopes, extras, plus authorizationResponseIssParameterSupported()). discover() tries the RFC 8414 OAuth Authorization Server Metadata endpoint (/.well-known/oauth-authorization-server) first and falls back to OIDC Discovery (/.well-known/openid-configuration) — the MCP 2026-07-28 spec requires both — and both perform the issuer-identity check (the returned document's issuer must exactly equal the requested one).
The full SEP-2351 path-insertion well-known-URI construction (path-bearing issuers, ordered fallbacks) is a deferred enhancement; OidcDiscoveryClient currently implements the two-endpoint (RFC 8414 → OIDC) fallback the baseline requires. An OIDC ID token returned alongside an access token is surfaced verbatim on OAuthToken.idToken() but is not validated in this baseline — do not trust its claims for auth decisions.
See it wired end to end. SecuredExampleClient in the secured/ package of juneau-examples-mcp drives this exact discovery chain with nothing hardcoded — from a 401 challenge through McpProtectedResourceMetadataClient (RFC 9728) and discoverAuthorizationServer(...) (RFC 8414) to a discovered token_endpoint it hands straight to McpTokenProvider.clientCredentials().
Dynamic Client Registration (SEP-837)
When you have no pre-registered client_id, McpDynamicClientRegistrar performs RFC 7591 / OIDC Dynamic Client Registration against an authorization server's registration_endpoint, built via McpDynamicClientRegistrar.create(). register() POSTs the client metadata and returns an immutable McpClientRegistration (clientId, clientSecret, clientSecretExpiresAt, registrationAccessToken, registrationClientUri, issuer, redirectUris, applicationType, extras) whose toString() redacts the client secret and registration-access-token. Registration failures (e.g. an AS rejecting a redirect URI) surface as an McpAuthException carrying the AS error code. Builder essentials:
registrationEndpoint,issuer— required; the endpoint must behttps(loopback exempt).applicationType(McpApplicationType)—NATIVE(the default for CLI/desktop/loopback clients) orWEB; a Juneau-owned enum so the public API does not leak the Nimbusprovidedtype. Per SEP-837 native apps (including CLI tools andlocalhost-accessed web apps) SHOULD register asnative.addRedirectUris(...)/addRedirectUri(...)— build loopback redirects withLoopbackRedirectUris:portAgnostic(path)→[http://127.0.0.1/callback, http://localhost/callback](RFC 8252 §7.3 port-agnostic matching) is the easy, spec-blessed default — the receiver may bind any ephemeral port and still match.forPort(port, path)is the bind-first strategy for strict exact-match ASes: register a specific fixed port, then have the acquirer bind that same port viaredirectPort(port)(see below) so the registered redirect matches exactly. Both strategies are now genuinely usable end to end.scope(...),addGrantType(...),addResponseType(...),clientName,confidential(...),initialAccessToken(...)(bearer for a protected registration endpoint, redacted intoString()).
McpClientRegistrations bridges a registration into the F1 flows: McpClientRegistrations.configure(acquirerBuilder, registration) and configure(tokenProviderBuilder, registration) apply the clientId, clientSecret, and registered loopback redirect so a DCR-obtained client drives McpAuthorizationCodeAcquirer / McpTokenProvider unchanged — still carrying the RFC 8707 resource= indicator and RFC 9207 iss validation (DCR changes only credential provenance). When the registration used the bind-first forPort(port, path) strategy, configure(...) carries both the redirect path and the fixed port onto the acquirer (wiring redirectPort(port)), so the receiver binds exactly the port the AS was told; a port-agnostic registration leaves the acquirer on its ephemeral default.
Issuer-bound credentials + re-registration (SEP-2352)
Per SEP-2352, credentials that are persisted MUST be keyed by the authorization server's issuer, kept separate per AS, and never reused across authorization servers. McpClientRegistrationStore is the persistence SPI (find(issuer) / put(issuer, registration) / remove(issuer)); InMemoryMcpClientRegistrationStore is the thread-safe default (redacts secrets in toString()). A caller may supply a durable implementation — it is responsible for at-rest protection of secrets and refresh tokens.
McpClientRegistrationManager orchestrates mechanism selection, issuer-keying, and migration. Given a discovered OidcMetadata (whose extras carry registration_endpoint) and an optional store, resolve(...) returns a usable McpClientRegistration:
- On-demand mode (no store) registers on every call — fully compliant (persistence is SHOULD-level).
- Store hit — reuses the issuer-keyed entry without a second DCR round-trip.
- Migration — when discovery now indicates a different issuer than a stored/pre-registered credential, the manager MUST NOT reuse the cross-AS credential: it re-registers via DCR (and stores under the new issuer), or, for pre-registered credentials, surfaces an
McpAuthExceptionrather than silently using a mismatched credential. A defensive issuer-mismatch check also guards against a buggy durable store returning an entry keyed under the wrong issuer. - An AS advertising neither a
registration_endpointnor pre-registered credentials yields anMcpAuthException.
The store SPI is three methods, all keyed by the issuer URI:
public interface McpClientRegistrationStore {
Optional<McpClientRegistration> find(URI issuer);
void put(URI issuer, McpClientRegistration registration);
void remove(URI issuer);
}
A minimal custom store (here backed by a map you would replace with a database or encrypted file) and its use with the manager:
import org.apache.juneau.rest.client.mcp.auth.*;
class MyRegistrationStore implements McpClientRegistrationStore {
private final Map<String,McpClientRegistration> byIssuer = new ConcurrentHashMap<>();
public Optional<McpClientRegistration> find(URI issuer) { return Optional.ofNullable(byIssuer.get(issuer.toString())); }
public void put(URI issuer, McpClientRegistration r) { byIssuer.put(issuer.toString(), r); }
public void remove(URI issuer) { byIssuer.remove(issuer.toString()); }
}
var manager = McpClientRegistrationManager.create()
.store(new MyRegistrationStore())
.clientName("my-cli")
.redirectUris(List.of(URI.create("http://127.0.0.1/callback")))
.build();
McpClientRegistration registration = manager.resolve(oidcMetadata); // oidcMetadata from OidcDiscoveryClient
The manager's builder also offers preRegistered(McpClientRegistration), applicationType, redirectUrisSupplier, scope, confidential, initialAccessToken, httpTimeout, and httpRequestConfigurator.
The manager migrates only the registration (client credentials) half. The token half of SEP-2352 is caller discipline: after an AS migration you must build a new McpTokenProvider (and re-run any interactive acquisition) for the new issuer — a token minted for the old AS is never carried across.
Scope accumulation on step-up (SEP-2350, client half)
When the resource server answers an operation with 403 insufficient_scope, the client re-authorizes with a broader grant and retries. McpScopeAccumulator.union(previouslyRequested, challengeScopes[, …opt-in contributors]) computes the mandated-minimum union of {previously-requested} ∪ {challenge} (order-preserving, exact-string dedup — scopes are opaque, so no hierarchy dedup client-side); optional contributors (granted-token scopes, PRM scopes_supported, client-metadata scope) widen it only when supplied.
McpStepUpAuthorizer is the step-up state machine, built via McpStepUpAuthorizer.create(). execute(String operation, ScopedCall<T> call) runs an MCP call under the given operation identifier; on an McpInsufficientScopeException (raised from a parsed WwwAuthenticateChallenge) it computes the new union, drives a caller-supplied Reauthorizer (a fresh auth-code+PKCE round-trip for user-delegation clients, or an M2M re-acquire), installs the new token, and retries — bounded by a hard attempt cap and per-(resource, operation) attempt tracking so a misconfigured server cannot drive an unbounded loop. Exhausting the cap throws a permanent McpAuthException. Step-up for user clients is inherently interactive, so this is an explicit orchestration helper you wrap around MCP calls — not a silent interceptor retry. client_credentials clients may be configured to abort immediately instead.
Attempt-counter lifetime. The per-(resource, operation) attempt counter is cleared automatically on a successful execute(...), so a later legitimate step-up for the same operation starts fresh rather than being permanently refused by a long-lived authorizer. To recover an operation that hit the cap (e.g. after fixing the underlying grant), call reset(operation); resetAll() clears every operation's counter for the authorizer's resource.
Response cache seam
McpResponseCache is opt-in and off by default. InMemoryMcpResponseCache is the built-in default implementation. The v2 adapter applies protocol cache hints (ttlMs, cacheScope) when available.
Revision binding
A client instance is revision-bound by construction:
- Use
org.apache.juneau.rest.client.mcp.v20250618.McpClientfor2025-06-18. - Use
org.apache.juneau.rest.client.mcp.v20260728.McpClientfor2026-07-28.
There is no automatic runtime revision switching.