MCP Recipes
Copy-pasteable snippets for common MCP tasks, targeting revision 2026-07-28 unless noted. Each recipe is self-contained: assume config is an McpServerConfig you're building up (as in the Quickstart), and paste imports as shown.
- Full example:
juneau-examples-mcp - Writing a tool
- Writing a prompt
- Serving a resource
- Serving a resource template with completions
- Error handling
- Elicitation / Multi-Round-Trip Requests (v2 only)
- MRTR key management (v2 only)
- Subscriptions (v2 only)
- Client: call, read, and handle errors
- Securing an MCP client and server with OAuth 2.1 (v2 only)
- Dynamic client registration and step-up (v2 only)
Full example: juneau-examples-mcp
Before diving into individual recipes, consider starting from juneau-examples-mcp — a complete, runnable "notes" service exercising every recipe on this page (and more) in one small, copy-me Maven module targeting revision 2026-07-28: a publishNote tool, a deleteNote tool that walks a full elicitation/MRTR confirm-then-resume round-trip, a summarize prompt with argument completion, a note:///index resource, a note:///{title} resource template with completion, and subscriptions/listen change notifications — plus an embedded-Jetty server launcher (ExampleServer), a guided McpClient walkthrough (ExampleClient), and a Spring Boot variant (SpringExampleMcpServer/SpringExampleApplication) using SpringMcpRestServlet. It ships as a downloadable project zip in the Apache release, alongside juneau-examples-core. See the module's own README.md for run instructions.
Writing a tool
Raw, via McpToolHandler.of(...) — full control over the input schema:
import java.util.*;
import org.apache.juneau.marshall.collections.JsonMap;
import org.apache.juneau.rest.server.mcp.*;
McpToolHandler weatherTool = McpToolHandler.of(
new McpToolSpec()
.setName("weather")
.setDescription("Looks up the current weather for a city.")
.setInputSchema(McpSchema.of(JsonMap.of(
"type", "object",
"properties", JsonMap.of("city", JsonMap.of("type", "string")),
"required", List.of("city")
))),
(arguments, ctx) -> {
String city = String.valueOf(arguments.get("city"));
return McpToolOutcome.text("Sunny in " + city);
}
);
config.addTool(weatherTool);
Typed, via McpTypedHandlers.tool(...) — derives the JSON Schema from your argument/result classes and binds/canonicalizes automatically:
import org.apache.juneau.commons.inject.BeanStore;
import org.apache.juneau.rest.server.mcp.*;
public class WeatherArgs {
public String city;
}
public class WeatherResult {
public String summary;
public double tempC;
}
McpToolHandler weatherTool = McpTypedHandlers.tool(
"weather", "Looks up the current weather for a city.",
WeatherArgs.class, WeatherResult.class,
(args, ctx) -> {
WeatherResult r = new WeatherResult();
r.summary = "Sunny";
r.tempC = 23.5;
return r;
}
);
config.addTool(weatherTool);
The typed path populates structuredContent on the CallToolResult (and mirrors it as a text block by default); the raw path leaves structuredContent unset unless you call McpToolOutcome.setStructuredContent(...) yourself.
Writing a prompt
import java.util.*;
import org.apache.juneau.rest.server.mcp.*;
McpPromptHandler greetPrompt = McpPromptHandler.of(
new McpPromptSpec()
.setName("greet")
.setDescription("Greets a named person.")
.setArguments(List.of(new McpPromptArgument().setName("name").setRequired(true))),
(arguments, ctx) -> new McpPromptOutcome()
.setMessages(List.of(new McpPromptMessage()
.setRole(McpRole.USER)
.setContent(McpContentBlock.text("Hello, " + arguments.get("name") + "!"))))
);
config.addPrompt(greetPrompt);
Serving a resource
import java.util.*;
import org.apache.juneau.rest.server.mcp.*;
McpResourceHandler readmeResource = McpResourceHandler.of(
new McpResourceSpec().setUri("file:///readme.txt").setName("readme").setMimeType("text/plain"),
(uri, ctx) -> new McpResourceOutcome()
.setContents(List.of(McpResourceContents.text(uri, "text/plain", "Welcome!")))
);
config.addResource(readmeResource);
Serving a resource template with completions
A template needs a named class (or anonymous class) rather than of(...) once you want a per-variable completer, since of(...) only wires descriptor() + read(...):
import java.util.*;
import org.apache.juneau.commons.inject.BeanStore;
import org.apache.juneau.rest.server.mcp.*;
public class FileResourceTemplate 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);
if (content == null)
return null; // reported to the client as resource-not-found
return new McpResourceOutcome().setContents(List.of(McpResourceContents.text(uri, "text/plain", content)));
}
@Override
public McpCompleter completer(String variableName) {
if (! "name".equals(variableName))
return null;
return (request, ctx) -> McpCompletionResult.empty().setValues(matchingFileNames(request.getValue()));
}
private String loadFile(String name) {
// your lookup logic
return null;
}
private List<String> matchingFileNames(String partial) {
// your completion logic
return List.of();
}
}
config.addResourceTemplate(new FileResourceTemplate());
A registration-only template (listable but with no read/complete behavior of its own) can skip the interface entirely: config.addResourceTemplate(new McpResourceTemplateSpec()...).
Error handling
Throw McpException with a JSON-RPC 2.0 standard error code to fail a call explicitly:
import org.apache.juneau.bean.jsonrpc.McpException;
// Standard JSON-RPC 2.0 codes (no dedicated constants class in Juneau — use the literals):
// -32700 Parse error
// -32600 Invalid Request
// -32601 Method not found
// -32602 Invalid params
// -32603 Internal error
// -32000..-32099 Server-defined error (reserved range for application use)
McpToolHandler.of(
new McpToolSpec().setName("weather").setDescription("Looks up the current weather for a city."),
(arguments, ctx) -> {
if (! arguments.containsKey("city"))
throw new McpException(-32602, "Missing required argument 'city'");
return McpToolOutcome.text("Sunny in " + arguments.get("city"));
}
);
Any other unchecked exception a handler lets escape is automatically converted to a -32603 ("Internal error") JSON-RPC response by the dispatcher — you don't need a catch-all yourself, but throwing McpException directly gets you a more specific code and message.
Elicitation / Multi-Round-Trip Requests (v2 only)
Elicitation (SEP-2322) lets a tool pause mid-call to ask the end user a question, then resume once the client relays an answer. It's built on Multi-Round-Trip Requests (MRTR), a 2026-07-28-only capability.
import java.util.*;
import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.commons.inject.BeanStore;
import org.apache.juneau.rest.server.mcp.*;
import org.apache.juneau.rest.server.mcp.v20260728.*;
McpToolHandler confirmDeleteTool = McpToolHandler.of(
new McpToolSpec().setName("confirm-delete").setDescription("Deletes a record after confirming with the user."),
(arguments, ctx) -> {
var resume = ctx.getBean(McpMrtrResumeContext.class);
if (resume.isEmpty()) {
var schema = ElicitSchema.create()
.booleanField("confirm").title("Confirm deletion?")
.required("confirm")
.build();
var question = new ElicitRequest().setMessage("Proceed with deletion?").setRequestedSchema(schema);
throw ElicitationRequests.of("confirm", question, "confirm-delete-continuation");
}
if (! ElicitationResponses.getBoolean(resume.get(), "confirm", "confirm"))
return McpToolOutcome.text("Deletion cancelled.");
// ... perform the deletion here ...
return McpToolOutcome.text("Deleted.");
}
);
- On the first round,
ctx.getBean(McpMrtrResumeContext.class)is empty, so the handler throwsElicitationRequests.of(id, request, continuation)— anMcpInputRequiredSignal(aRuntimeException) that the dispatcher turns into aninput_requiredpause. - The client answers, then re-sends the call; on that round,
McpMrtrResumeContextis present.ElicitationResponses.getBoolean(ctx, id, field)reads a single boolean field decline/omission-safely in one call — a missing id, a missing field, or a non-ACCEPTaction (declined/cancelled) all resolve tofalse, so there's no need to null-checkget(...)and its content map by hand for a plain confirm/deny question.ElicitationResponses.getString(ctx, id, field)is the equivalent for a string-typed field (resolving tonullon the same conditions);ElicitationResponses.get(ctx, id)/all(ctx)remain available when you need the full typedElicitResult. - If a handler needs to carry state across rounds (e.g. which record to delete), stash it in the
continuationargument toElicitationRequests.of(...)and recover it on resume viaresume.get().continuationAsString()(orcontinuationAs(Class)for a non-Stringshape) — see MRTR resume contexts in the server reference.
This requires no special server config beyond a v2 servlet/mixin — MRTR support is on by default (see McpOptions.mrtr(Consumer<McpMrtrConfig>) in the server reference to customize the codec, TTL, or round cap).
Client side: answering the elicitation
On the client, McpClient.callToolWithElicitation(...) (and its getPromptWithElicitation/readResourceWithElicitation siblings) drives the whole pause/resume loop for you — supply an McpElicitationHandler and get back the terminal typed result. No hand-managed requestState/inputResponses:
import java.util.*;
import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.rest.client.mcp.v20260728.*;
var result = client.callToolWithElicitation("confirm-delete", Map.of(), requests -> {
var answers = new LinkedHashMap<String,ElicitResult>();
requests.forEach((id, req) -> // req.getMessage() → "Proceed with deletion?"
answers.put(id, new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true)));
return answers;
});
// result is a CallToolResult; the input_required round(s) were answered and resumed transparently.
The handler is invoked once per input_required pause and may answer several requests in one round. Return an ElicitResult with ElicitAction.DECLINE/CANCEL to refuse (the server decides the outcome). The loop is bounded by McpClient.DEFAULT_MAX_ELICITATION_ROUNDS (override via a maxRounds argument); exceeding it throws McpElicitationLimitException. For full manual control, callRaw(...) + ElicitationRequests/ElicitationResponses remain available — see the client reference.
MRTR key management (v2 only)
By default, MRTR requestState tokens are sealed under a random key generated once per process (EphemeralKeyProvider, the zero-config default beneath AeadRequestStateCodec) — deliberately not shareable across instances and not durable across a restart (see the server reference). A load-balanced deployment, where RESUME can land on a different node than the PAUSE, needs a shared sealing key instead.
Shared sealing key for horizontally-scaled RESUME
Supply a StaticKeyProvider loaded from config/secret via setKeyProvider(...):
import org.apache.juneau.rest.server.mcp.v20260728.*;
byte[] secretBytes = loadSharedKeyFromSecretStore(); // your config/secret source, 32 bytes for AES-256
KeyProvider sharedKeyProvider = StaticKeyProvider.of("2026-08-a", StaticKeyProvider.aesKey(secretBytes));
// servlet path
@Override
protected McpOptions createMcpOptions() {
return new McpOptions().mrtr(m -> m.setKeyProvider(sharedKeyProvider));
}
Every process that constructs setKeyProvider(sharedKeyProvider) — even independently, on separate nodes — mints and resolves tokens under the same key, so a token PAUSEd on one node RESUMEs successfully on another. To roll the key without invalidating tokens already in flight, use the multi-key builder instead: add the new key as current while keeping the old one resolvable until every token sealed under it has expired (bounded by your configured ttlMs):
KeyProvider rollingKeyProvider = StaticKeyProvider.create()
.addKey("2026-08-a", StaticKeyProvider.aesKey(oldSecretBytes))
.addKey("2026-08-b", StaticKeyProvider.aesKey(newSecretBytes))
.current("2026-08-b")
.build();
Bring-your-own vault KeyProvider
For a real secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.), implement KeyProvider directly against your secret store instead of the static built-in:
import java.util.Optional;
import javax.crypto.SecretKey;
import org.apache.juneau.rest.server.mcp.v20260728.*;
public class VaultKeyProvider implements KeyProvider {
private final MySecretStoreClient vault; // your client
public VaultKeyProvider(MySecretStoreClient vault) {
this.vault = vault;
}
@Override
public KeyedSecret currentKey() {
var current = vault.currentMrtrKey(); // keyId + key bytes, your lookup
return new KeyedSecret(current.keyId(), StaticKeyProvider.aesKey(current.keyBytes()));
}
@Override
public Optional<SecretKey> resolveKey(String keyId) {
return vault.findMrtrKey(keyId).map(k -> StaticKeyProvider.aesKey(k.keyBytes()));
}
}
@Override
protected McpOptions createMcpOptions() {
return new McpOptions().mrtr(m -> m.setKeyProvider(new VaultKeyProvider(vaultClient)));
}
resolveKey(keyId) must return Optional.empty() for an unknown or retired keyId rather than throwing — an unresolvable keyId fails the RESUME closed as the standard -32602 "Invalid or tampered requestState" error, exactly like a tampered token. Rotate by having currentKey() start returning a new keyId/key pair while resolveKey(...) keeps resolving the previous one until every token sealed under it has expired.
Subscriptions (v2 only)
Subscriptions (SEP-2575) let a server push resource/list-changed notifications to a client over a long-held subscriptions/listen stream. This is v2-only and requires juneau-rest-server-reactive on the server's classpath for the async held-open SSE transport.
Server: emit a change. Inject McpSubscriptions via the handler's BeanStore and call it from any tool/resource/prompt handler:
import org.apache.juneau.commons.inject.BeanStore;
import org.apache.juneau.rest.server.mcp.*;
McpToolHandler touchFileTool = McpToolHandler.of(
new McpToolSpec().setName("touch-file").setDescription("Marks a resource as updated."),
(arguments, ctx) -> {
String uri = String.valueOf(arguments.get("uri"));
ctx.getBean(McpSubscriptions.class)
.orElseThrow(() -> new IllegalStateException("McpSubscriptions bean missing — is this endpoint bound to v20260728?"))
.resourceUpdated(uri);
return McpToolOutcome.text("published");
}
);
McpSubscriptions also exposes toolsListChanged(), promptsListChanged(), and resourcesListChanged() for list-level notifications.
Optionally tune the broker (queue size, heartbeat, idle timeout) via the subscriptions(Consumer<McpSubscriptionsConfig>) configure-block on your servlet/endpoint's McpOptions:
// servlet path
@Override
protected McpOptions createMcpOptions() {
return new McpOptions().subscriptions(s -> s.setIdleTimeoutMs(5 * 60_000L));
}
// mixin path
@Override
public McpOptions getMcpOptions() {
return new McpOptions().subscriptions(s -> s.setIdleTimeoutMs(5 * 60_000L));
}
idleTimeoutMs defaults to disabledMcpSubscriptionsConfig.getIdleTimeoutMs() defaults to 0, which disables idle-timeout reaping — a subscription stream stays open indefinitely as long as the connection itself stays up. If you set it to a positive value, the server auto-closes a stream that has delivered no notifications or heartbeats for that many milliseconds. A long-lived client listener should treat onComplete()/onError(...) as a signal to call listen(...) again, not assume the stream stays open forever once idle timeouts are enabled.
Client: listen for changes:
import java.util.*;
import org.apache.juneau.bean.mcp.v20260728.SubscriptionFilter;
import org.apache.juneau.rest.client.mcp.v20260728.*;
var client = McpClient.connect("http://localhost:8080/mcp");
var filter = new SubscriptionFilter()
.setResourceSubscriptions(List.of("file:///a.txt"))
.setResourcesListChanged(true);
McpSubscriptionHandle handle = client.listen(filter, new McpSubscriptionListener() {
@Override public void onAcknowledged(SubscriptionFilter honoredFilter) {
System.out.println("Subscribed: " + honoredFilter);
}
@Override public void onResourceUpdated(String uri) {
System.out.println("Resource updated: " + uri);
}
@Override public void onListChanged(McpListChangedKind kind) {
System.out.println("List changed: " + kind);
}
@Override public void onComplete() {
System.out.println("Server closed the subscription gracefully.");
}
@Override public void onError(Throwable t) {
t.printStackTrace();
}
});
// ... later, when you're done listening:
handle.close();
listen(...) opens the stream synchronously (a transport failure surfaces as a thrown IOException right there) then dispatches every subsequent frame to listener on a background thread — onAcknowledged(...) always arrives first, echoing the server-honored (capability-gated) subset of your requested filter.
Client: call, read, and handle errors
import java.util.*;
import org.apache.juneau.bean.jsonrpc.McpException;
import org.apache.juneau.rest.client.mcp.v20260728.McpClient;
try (var client = McpClient.connect("http://localhost:8080/mcp")) {
System.out.println("Connected to: " + client.discoveredServer().getServerInfo());
var tools = client.listTools();
System.out.println(tools);
var callResult = client.callTool("weather", Map.of("city", "Seattle"));
System.out.println(callResult.getContent());
// Just want the tool's text? callToolText(...) is callTool(...) + CallToolResult.firstText() in one
// call -- no manual ((TextContent) callResult.getContent().get(0)).getText() cast-and-scan needed.
System.out.println(client.callToolText("weather", Map.of("city", "Seattle")));
var resource = client.readResource("file:///readme.txt");
System.out.println(resource.getContents());
} catch (McpException e) {
System.err.println("Server returned JSON-RPC error " + e.getCode() + ": " + e.getMessage());
} catch (java.io.IOException e) {
System.err.println("Transport failure: " + e.getMessage());
}
McpClient is Closeable, so try-with-resources closes the underlying transport when the block exits, on either the success or error path. Every typed call (callTool, getPrompt, readResource, listTools, listPrompts, listResources, listResourceTemplates, complete) throws McpException for a JSON-RPC error response and IOException for a transport/(de)serialization failure — there's no single unified exception type, so a caller that needs both should catch both, as shown.
ServerDiscoverResult.getServerInfo() and McpClient.callToolText(...) are additive convenience methods: the former is a null-safe shortcut to the server's Implementation identity off the server/discover result that connect(...) (and serverDiscover()) already cache, and the latter folds callTool(...) + CallToolResult.firstText() into one call for the common case of wanting just a tool's rendered text. Neither replaces the lower-level getContent()/getMeta() access shown above — both remain available whenever you need the full result.
Securing an MCP client and server with OAuth 2.1 (v2 only)
This recipe walks both sides of an OAuth 2.1 / MCP 2026-07-28 deployment as one story: turn a v2 MCP endpoint into a protected resource server, then acquire a token on the client and call it. Authorization is v2-only and off by default — an endpoint that doesn't opt in behaves exactly as before.
Prefer to start from running code? The secured/ package inside juneau-examples-mcp is the canonical, copy-me, fully offline realization of this entire recipe — and it ships as part of the same downloadable project zip in the Apache release. SecuredExampleMcpServer layers the resource-server config below onto the plain ExampleMcpServer with a single createMcpOptions() override (baseline mcp.read, plus a SEP-2350 per-operation step-up requiring mcp.write for the publishNote/deleteNote tools); an in-process OfflineAuthorizationServer stands in for a real AS (a self-generated RSA signing key, a real RFC 6749 §4.4 /token endpoint, and an RFC 8414 /.well-known/oauth-authorization-server discovery document) so the whole thing runs with no external IdP and no network access. SecuredExampleClient then drives the complete discovery chain end to end: an unauthenticated call rejected with 401 + a WWW-Authenticate challenge, RFC 9728 Protected Resource Metadata discovery, RFC 8414 authorization-server discovery of the token_endpoint, a mcp.read-scoped token that reads successfully, the same token rejected with 403 insufficient_scope on a write, and a step-up to a mcp.read mcp.write token that publishes successfully. See the module's own README.md for run instructions.
Dependencies. On the server, add a token-validator module alongside juneau-rest-server-mcp-v20260728: juneau-rest-server-auth-jwt (JWT/JWKS validation) or juneau-rest-server-auth-oauth (RFC 7662 introspection). On the client, add juneau-rest-client-mcp-auth alongside juneau-rest-client-mcp-v20260728:
<!-- client -->
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-client-mcp-auth</artifactId>
<version>${juneau.version}</version>
</dependency>
Server: turn on the resource server
RS auth is configured on the v2 McpOptions via its resourceServer(Consumer<McpResourceServerConfig>) block. The minimal working setup enables it, declares the canonical resource identifier, and supplies a TokenValidator:
import java.net.URI;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.auth.jwt.JwtTokenValidator;
import org.apache.juneau.rest.server.mcp.*;
import org.apache.juneau.rest.server.mcp.v20260728.*;
import org.apache.juneau.rest.server.servlet.*;
@Rest // root-mounted host: MCP is served at POST /mcp on the origin root (see the origin-root callout below)
public class SecureApi extends BasicRestServlet implements McpEndpoint {
@Override
public McpServerConfig getMcpConfig() {
return new McpServerConfig().setName("my-server").setVersion("1.0.0").addTool(new EchoTool());
}
@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")) // RFC 9728 resource identity / RFC 8707 audience default
.setTokenValidator(validator) // JWT mode; use OAuthIntrospectionValidator for opaque tokens
.addAuthorizationServer(URI.create("https://login.example.com/realms/api"))
.addRequiredScope("mcp.read"));
}
}
Once enabled, the endpoint:
- Serves an RFC 9728 Protected Resource Metadata (PRM) document — unauthenticated — from the origin-root well-known location
/.well-known/oauth-protected-resource[/<resource-path>], advertising theauthorization_servers,scopes_supported, andbearer_methods_supportedyou configured. (While RS auth is disabled, that route returns404.) - Requires a valid RFC 6750 bearer token on the MCP
POSTendpoint. A missing token is answered with401+WWW-Authenticate: Bearer realm="mcp", resource_metadata="…"; an invalid/expired token or audience mismatch addserror="invalid_token"; a token missing a required scope is403+error="insufficient_scope", scope="…". Every challenge carries theresource_metadatapointer so a client can discover where to get a token. - Enforces RFC 8707 audience matching against the resource identifier (or an explicit
setAudience(...)). By default (requireAudienceClaim = true) a validated principal that exposes no audience claims is rejected — setsetRequireAudienceClaim(false)only when the validator itself verifiesaudinternally (e.g. aJwtTokenValidatorwithaudience(...)).
RFC 9728 requires the PRM document to be discoverable at the origin-root well-known location (https://host/.well-known/oauth-protected-resource[/path]) — which is exactly the URL advertised in the resource_metadata challenge. Juneau registers the well-known routes servlet-relative, so a context path, a non-root servlet path, or an @Mixin(path=...) re-mount prefix would move the advertised URL somewhere it can't be reached. Rather than silently advertising an unreachable document, RS auth fails fast with a 500 carrying an origin-root diagnostic. Mount a dedicated McpRestServlet at /*, or (as above) implement McpEndpoint on a root-mounted host resource with no @Mixin(path=...) prefix. (A configurable external PRM URL that would relax this is a deferred future enhancement, not a current feature.)
Client: acquire a token (easy path first)
All token acquisition funnels through the McpAuthInterceptor token seam — a Supplier<String> that yields the current bearer token per request. McpTokenProvider implements that supplier and exposes interceptor() to attach it to a client builder.
1. Pre-provisioned / static token — the simplest path when you already hold a token:
import org.apache.juneau.rest.client.mcp.auth.*;
import org.apache.juneau.rest.client.mcp.v20260728.McpClient;
var tokens = McpTokenProvider.ofStaticToken("eyJhbGciOi…");
var client = McpClient.connect(McpClient.builder()
.endpoint("https://api.example.com/mcp")
.interceptor(tokens.interceptor()));
2. Client-credentials (RFC 6749 §4.4) — headless service-to-service; re-acquired automatically on expiry:
import java.net.URI;
import org.apache.juneau.rest.client.mcp.auth.*;
var tokens = McpTokenProvider.clientCredentials()
.tokenEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/token"))
.clientId("my-client")
.clientSecret("s3cr3t")
.resource(URI.create("https://api.example.com/mcp")) // RFC 8707 — REQUIRED for the dynamic modes
.scope("mcp.read")
.build();
3. Refresh-token (RFC 6749 §6, SEP-2207) — long-lived, with automatic rotation capture:
var tokens = McpTokenProvider.refreshToken(savedRefreshToken)
.tokenEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/token"))
.clientId("my-client")
.resource(URI.create("https://api.example.com/mcp"))
.build();
// After each request, persist a rotated refresh token so a restart can resume:
tokens.currentRefreshToken().ifPresent(this::saveRefreshToken);
A rotated refresh token the IdP returns is captured and used for the next refresh with no re-consent. A terminal invalid_grant latches the provider: every later get() throws an McpAuthException (re-authorization required) rather than re-hammering the IdP with a dead token.
4. Interactive authorization-code + PKCE (SEP-837 loopback, SEP-2468 iss check) — for a CLI/native app where a human authorizes in a browser:
import java.net.URI;
import java.time.Duration;
import org.apache.juneau.rest.client.mcp.auth.*;
var acquirer = McpAuthorizationCodeAcquirer.create()
.authorizationEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/auth"))
.tokenEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/token"))
.clientId("my-native-client")
.resource(URI.create("https://api.example.com/mcp")) // RFC 8707 — REQUIRED
.expectedIssuer(URI.create("https://login.example.com/realms/api")) // SEP-2468 — REQUIRED (or skipIssuerValidation(true))
.offlineAccess(true) // add offline_access → the IdP issues a refresh token
.scope("mcp.read")
.build(); // default browserLauncher prints the URL; supply browserLauncher(...) to open a real browser
var token = acquirer.acquire(Duration.ofMinutes(2)); // opens a loopback listener, waits for the redirect, exchanges the code
The acquirer opens a LoopbackRedirectReceiver on an ephemeral 127.0.0.1 port, hands the authorization URL to the browser launcher, validates the callback (single-use state for CSRF, iss against expectedIssuer), and exchanges the code with PKCE S256. Because offlineAccess(true) requested a refresh token, hand it to a refresh-mode provider so the client stays authenticated without re-prompting:
var tokens = McpTokenProvider.refreshToken(token.refreshToken().orElseThrow())
.tokenEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/token"))
.clientId("my-native-client")
.resource(URI.create("https://api.example.com/mcp"))
.build();
Let PRM discovery drive authorization-server selection
Instead of hardcoding the token/authorization endpoints, let the server's PRM document point the way (RFC 9728 → RFC 8414 / OIDC Discovery):
import java.net.URI;
import org.apache.juneau.rest.client.mcp.auth.*;
var prmClient = McpProtectedResourceMetadataClient.create()
.expectedResource(URI.create("https://api.example.com/mcp")) // RFC 9728 §3.3 identity check — rejects a PRM minted for a different resource
.build();
// Fetch the PRM from the origin-root well-known location...
var prm = prmClient.fetch(URI.create("https://api.example.com/.well-known/oauth-protected-resource/mcp"));
// ...or recover the pointer from a 401 challenge header:
// WwwAuthenticateChallenge.parse(wwwAuthenticateHeader).flatMap(WwwAuthenticateChallenge::resourceMetadata)
// Discover + validate the authorization server the PRM delegates to (tries RFC 8414, falls back to OIDC Discovery):
var as = prmClient.discoverAuthorizationServer(prm);
var tokens = McpTokenProvider.clientCredentials()
.tokenEndpoint(as.tokenEndpoint())
.clientId("my-client")
.clientSecret("s3cr3t")
.resource(prm.resource())
.scope("mcp.read")
.build();
discoverAuthorizationServer(...) selects the first advertised authorization_servers entry, discovers its metadata, and verifies the discovered issuer exactly matches the requested one. By default the PRM URL and any discovered AS URL must use https (loopback hosts are always allowed for local testing); allowInsecureHttp(true) disables that check.
Putting it together
With a provider wired into the interceptor, an authenticated call is indistinguishable from an unauthenticated one — the bearer header is attached transparently on every request:
import java.util.Map;
import org.apache.juneau.rest.client.mcp.v20260728.McpClient;
try (var client = McpClient.connect(McpClient.builder()
.endpoint("https://api.example.com/mcp")
.interceptor(tokens.interceptor()))) {
var result = client.callTool("echo", Map.of("text", "hello"));
System.out.println(result.getContent());
}
If the token is missing, expired, or under-scoped, the server answers with the 401/403 challenge above and the call surfaces the failure — recover by re-acquiring (or re-authorizing) and retrying. See the juneau-rest-client-mcp auth reference and the juneau-rest-server-mcp resource-server reference for the full API surface.
Dynamic client registration and step-up (v2 only)
Two authorization-hardening recipes for the 2026-07-28 client: registering a client on the fly (RFC 7591 / OIDC DCR, SEP-837 / SEP-2352) and re-authorizing with broader scopes when the server asks (SEP-2350). Both build on the OAuth 2.1 recipe above.
Register a client on the fly (DCR)
When you have no pre-provisioned client_id, register one against the AS registration_endpoint, then bridge the result straight into the interactive acquirer. portAgnostic(...) is the easy default — the receiver may bind any port:
import java.net.URI;
import java.time.Duration;
import org.apache.juneau.rest.client.mcp.auth.*;
var registration = McpDynamicClientRegistrar.create()
.registrationEndpoint(URI.create("https://as.example.com/register"))
.issuer(URI.create("https://as.example.com"))
.applicationType(McpApplicationType.NATIVE) // SEP-837 SHOULD for CLI/desktop/loopback clients
.addRedirectUris(LoopbackRedirectUris.portAgnostic("/callback"))
.scope("mcp.read", "mcp.write")
.clientName("my-cli")
.register(); // POSTs metadata; returns a secret-redacting McpClientRegistration
// Bridge the DCR result into the interactive auth-code + PKCE flow (credential provenance only — resource/iss unchanged):
var acquirer = McpClientRegistrations.configure(
McpAuthorizationCodeAcquirer.create()
.authorizationEndpoint(URI.create("https://as.example.com/authorize"))
.tokenEndpoint(URI.create("https://as.example.com/token"))
.resource(URI.create("https://api.example.com/mcp"))
.expectedIssuer(URI.create("https://as.example.com")),
registration)
.build();
var token = acquirer.acquire(Duration.ofMinutes(2)); // opens the loopback receiver, launches the browser, exchanges the code
Strict exact-match AS? Register a fixed port with forPort(...) instead; configure(...) carries that port onto the acquirer so the receiver binds exactly what the AS was told:
int port = 49215; // any free loopback port you reserve
var registration = McpDynamicClientRegistrar.create()
.registrationEndpoint(URI.create("https://as.example.com/register"))
.issuer(URI.create("https://as.example.com"))
.applicationType(McpApplicationType.NATIVE)
.addRedirectUris(LoopbackRedirectUris.forPort(port, "/callback"))
.register();
// McpClientRegistrations.configure(acquirerBuilder, registration) now also wires redirectPort(port).
Persist across runs (SEP-2352). Let McpClientRegistrationManager key credentials by issuer and re-register on AS migration, backed by a store:
var manager = McpClientRegistrationManager.create()
.store(new InMemoryMcpClientRegistrationStore()) // or a durable, at-rest-protected implementation
.redirectUris(LoopbackRedirectUris.portAgnostic("/callback"))
.scope("mcp.read")
.clientName("my-cli")
.build();
// 'as' is the discovered OidcMetadata (its extras carry registration_endpoint) from PRM discovery above.
var registration = manager.resolve(as); // store-hit reuse, or a fresh DCR keyed under as.issuer()
After an AS migration you must build a new McpTokenProvider for the new issuer — the manager migrates the client-registration half, but never carries a token across authorization servers.
Re-authorize with broader scopes on step-up
When the server answers a privileged operation with 403 insufficient_scope, wrap the call in an McpStepUpAuthorizer: it unions the challenged scopes onto what you already requested, re-authorizes, and retries under a bounded attempt cap.
import java.net.URI;
import org.apache.juneau.rest.client.mcp.auth.*;
var authorizer = McpStepUpAuthorizer.create()
.resource(URI.create("https://api.example.com/mcp"))
.issuer(URI.create("https://as.example.com"))
.previouslyRequestedScopes("mcp.read")
.reauthorizer(scopes -> {
// Re-acquire a token for the widened scope union and install it on the client.
// For a user-delegation client this is a fresh auth-code + PKCE round-trip; for M2M, a client_credentials re-acquire.
})
.build();
var result = authorizer.execute("tools/call:deploy", () -> {
// Run the MCP call. On a 403 insufficient_scope, throw McpInsufficientScopeException(challenge)
// (parse the WWW-Authenticate header via WwwAuthenticateChallenge.parse(...)) so the authorizer can step up and retry.
return client.callTool("deploy", args);
});
A successful execute(...) clears that operation's attempt counter, so a later legitimate step-up starts fresh; call reset("tools/call:deploy") (or resetAll()) to recover an operation that hit the cap after you fix the underlying grant. A client_credentials client can be configured with attemptStepUp(false) to abort immediately instead of retrying.
Next steps
- juneau-rest-server-mcp reference — capabilities, pagination, cache hints, tracing, MRTR internals.
- juneau-rest-client-mcp reference — full client API, including the duplex server-request channel and response caching.