Add URL elicitation support (SEP-1036) (#993)

Add URL-type elicitation schema support allowing servers to request URL
input from users during tool execution. This enables out-of-band
interactions like payment processing or API key entry.

Breaking changes:

- `ElicitRequest` changed from a `record` to an `interface`.
- The original `ElicitRequest` record was renamed to `ElicitFormRequest`.
- `McpClient` builder `elicitation()` methods now accept `ElicitFormRequest` instead of `ElicitRequest`.

New APIs:

- `ElicitUrlRequest` record for URL-mode elicitation.
- `urlElicitation()` builder methods in `McpClient`.
- `elicitationCompleteConsumer()` and `elicitationCompleteConsumers()` builder methods in `McpClient`.
- `sendElicitationComplete()` methods in `McpAsyncServer` and `McpSyncServer`.
- `McpError.URL_ELICITATION_REQUIRED` and `McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED`.
- `ElicitationCompleteNotification` record and `METHOD_NOTIFICATION_ELICITATION_COMPLETE` constant.

Co-authored-by: Daniel Garnier-Moiroux <git@garnier.wf>
Co-authored-by: Sainath Reddy Bobbala <bsnr@amazon.com>
This commit is contained in:
Daniel Garnier-Moiroux 2026-06-03 16:24:29 +02:00 • committed by GitHub
commit dbb9bdad51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1413 additions and 196 deletions

View file

@ -15,9 +15,6 @@ import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.modelcontextprotocol.client.LifecycleInitializer.Initialization;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
@ -29,8 +26,10 @@ import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitUrlRequest;
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult;
@ -41,6 +40,8 @@ import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.ToolNameValidator;
import io.modelcontextprotocol.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@ -108,6 +109,9 @@ public class McpAsyncClient {
public static final TypeRef<McpSchema.ProgressNotification> PROGRESS_NOTIFICATION_TYPE_REF = new TypeRef<>() {
};
public static final TypeRef<McpSchema.ElicitationCompleteNotification> ELICITATION_COMPLETE_NOTIFICATION_TYPE_REF = new TypeRef<>() {
};
public static final String NEGOTIATED_PROTOCOL_VERSION = "io.modelcontextprotocol.client.negotiated-protocol-version";
/**
@ -145,7 +149,14 @@ public class McpAsyncClient {
* necessary information dynamically. Servers can request structured data from users
* with optional JSON schemas to validate responses.
*/
private Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler;
private Function<ElicitFormRequest, Mono<ElicitResult>> formElicitationHandler;
/**
* MCP provides a standardized way for servers to request additional information from
* users out-of-band during interactions. This flow allows users to share information
* with the server without sharing it with the client.
*/
private Function<ElicitUrlRequest, Mono<ElicitResult>> urlElicitationHandler;
/**
* Client transport implementation.
@ -226,11 +237,21 @@ public class McpAsyncClient {
// Elicitation Handler
if (this.clientCapabilities.elicitation() != null) {
if (features.elicitationHandler() == null) {
// elicitation: {} is equivalent to elicitation: { form: {} } for
// backwards-compatiblity
var supportsForm = this.clientCapabilities.elicitation().form() != null
|| this.clientCapabilities.elicitation().url() == null;
var supportsUrl = this.clientCapabilities.elicitation().url() != null;
if (supportsForm && features.formElicitationHandler() == null) {
throw new IllegalArgumentException(
"Elicitation handler must not be null when client capabilities include elicitation");
"Form elicitation handler must not be null when client capabilities include form elicitation");
}
this.elicitationHandler = features.elicitationHandler();
if (supportsUrl && features.urlElicitationHandler() == null) {
throw new IllegalArgumentException(
"URL elicitation handler must not be null when client capabilities include URL elicitation");
}
this.formElicitationHandler = features.formElicitationHandler();
this.urlElicitationHandler = features.urlElicitationHandler();
requestHandlers.put(McpSchema.METHOD_ELICITATION_CREATE, elicitationCreateHandler());
}
@ -301,6 +322,16 @@ public class McpAsyncClient {
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROGRESS,
asyncProgressNotificationHandler(progressConsumersFinal));
// Elicitation Complete Notification
List<Function<McpSchema.ElicitationCompleteNotification, Mono<Void>>> elicitationCompleteConsumersFinal = new ArrayList<>();
elicitationCompleteConsumersFinal
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Elicitation complete: {}", notification)));
if (!Utils.isEmpty(features.elicitationCompleteConsumers())) {
elicitationCompleteConsumersFinal.addAll(features.elicitationCompleteConsumers());
}
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ELICITATION_COMPLETE,
asyncElicitationCompleteNotificationHandler(elicitationCompleteConsumersFinal));
Function<Initialization, Mono<Void>> postInitializationHook = init -> {
if (init.initializeResult().capabilities().tools() == null || !enableCallToolSchemaCaching) {
@ -552,23 +583,47 @@ public class McpAsyncClient {
};
}
// --------------------------
// Elicitation
// --------------------------
private RequestHandler<ElicitResult> elicitationCreateHandler() {
return params -> {
ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() {
McpSchema.ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() {
});
return this.elicitationHandler.apply(request).map(result -> {
if (this.applyElicitationDefaults && result.action() == ElicitResult.Action.ACCEPT
&& result.content() != null) {
Map<String, Object> merged = new HashMap<>(result.content());
applyElicitationDefaults(request.requestedSchema(), merged);
return new ElicitResult(result.action(), merged, result.meta());
if (request instanceof ElicitUrlRequest urlRequest) {
if (this.urlElicitationHandler == null) {
return Mono.error(new IllegalStateException(
"Received URL elicitation request, but urlElicitation handler is null"));
}
return result;
});
return this.urlElicitationHandler.apply(urlRequest);
}
else if (request instanceof ElicitFormRequest formRequest) {
if (this.formElicitationHandler == null) {
return Mono.error(new IllegalStateException(
"Received FORM elicitation request, but formElicitationHandler handler is null"));
}
return this.formElicitationHandler.apply(formRequest).map(result -> {
if (this.applyElicitationDefaults && result.action() == ElicitResult.Action.ACCEPT
&& result.content() != null) {
Map<String, Object> merged = new HashMap<>(result.content());
applyElicitationDefaults(formRequest.requestedSchema(), merged);
return new ElicitResult(result.action(), merged, result.meta());
}
return result;
});
}
return Mono.error(new IllegalStateException("Unknown elictation type deserialized"));
};
}
private NotificationHandler asyncElicitationCompleteNotificationHandler(
List<Function<McpSchema.ElicitationCompleteNotification, Mono<Void>>> elicitationCompleteConsumers) {
return params -> {
McpSchema.ElicitationCompleteNotification notification = transport.unmarshalFrom(params,
ELICITATION_COMPLETE_NOTIFICATION_TYPE_REF);
return Flux.fromIterable(elicitationCompleteConsumers)
.flatMap(consumer -> consumer.apply(notification))
.then();
};
}

View file

@ -4,22 +4,6 @@
package io.modelcontextprotocol.client;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import io.modelcontextprotocol.spec.McpSchema.Implementation;
import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.spec.McpTransport;
import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
@ -29,6 +13,23 @@ import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitUrlRequest;
import io.modelcontextprotocol.spec.McpSchema.Implementation;
import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.spec.McpTransport;
import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;
/**
* Factory class for creating Model Context Protocol (MCP) clients. MCP is a protocol that
* enables AI models to interact with external tools and resources through a standardized
@ -185,9 +186,13 @@ public interface McpClient {
private final List<Consumer<McpSchema.ProgressNotification>> progressConsumers = new ArrayList<>();
private final List<Consumer<McpSchema.ElicitationCompleteNotification>> elicitationCompleteConsumers = new ArrayList<>();
private Function<CreateMessageRequest, CreateMessageResult> samplingHandler;
private Function<ElicitRequest, ElicitResult> elicitationHandler;
private Function<ElicitFormRequest, ElicitResult> formElicitationHandler;
private Function<ElicitUrlRequest, ElicitResult> urlElicitationHandler;
private Supplier<McpTransportContext> contextProvider = () -> McpTransportContext.EMPTY;
@ -314,9 +319,24 @@ public interface McpClient {
* @return This builder instance for method chaining
* @throws IllegalArgumentException if elicitationHandler is null
*/
public SyncSpec elicitation(Function<ElicitRequest, ElicitResult> elicitationHandler) {
public SyncSpec elicitation(Function<ElicitFormRequest, ElicitResult> elicitationHandler) {
Assert.notNull(elicitationHandler, "Elicitation handler must not be null");
this.elicitationHandler = elicitationHandler;
this.formElicitationHandler = elicitationHandler;
return this;
}
/**
* Sets a custom elicitation handler for processing URL-mode elicitation message
* requests. The elicitation handler can modify or validate messages before they
* are sent to the server, enabling custom processing logic.
* @param elicitationHandler A function that processes elicitation requests and
* returns results. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if elicitationHandler is null
*/
public SyncSpec urlElicitation(Function<ElicitUrlRequest, ElicitResult> elicitationHandler) {
Assert.notNull(elicitationHandler, "Elicitation handler must not be null");
this.urlElicitationHandler = elicitationHandler;
return this;
}
@ -439,6 +459,36 @@ public interface McpClient {
return this;
}
/**
* Adds a consumer to be notified by the server when an URL elicitation is
* complete.
* @param elicitationCompleteConsumer A consumer that receives elicitation
* complete notifications. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if elicitationCompleteConsumer is null
*/
public SyncSpec elicitationCompleteConsumer(
Consumer<McpSchema.ElicitationCompleteNotification> elicitationCompleteConsumer) {
Assert.notNull(elicitationCompleteConsumer, "Elicitation complete consumer must not be null");
this.elicitationCompleteConsumers.add(elicitationCompleteConsumer);
return this;
}
/**
* Adds multiple consumers to be notified by the server when an URL elicitation is
* complete.
* @param elicitationCompleteConsumers A list of consumers that receives
* elicitation complete notifications. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if elicitationCompleteConsumers is null
*/
public SyncSpec elicitationCompleteConsumers(
List<Consumer<McpSchema.ElicitationCompleteNotification>> elicitationCompleteConsumers) {
Assert.notNull(elicitationCompleteConsumers, "Elicitation complete consumers must not be null");
this.elicitationCompleteConsumers.addAll(elicitationCompleteConsumers);
return this;
}
/**
* Add a provider of {@link McpTransportContext}, providing a context before
* calling any client operation. This allows to extract thread-locals and hand
@ -502,8 +552,9 @@ public interface McpClient {
public McpSyncClient build() {
McpClientFeatures.Sync syncFeatures = new McpClientFeatures.Sync(this.clientInfo, this.capabilities,
this.roots, this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers,
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers, this.samplingHandler,
this.elicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults);
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers,
this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler,
this.urlElicitationHandler, this.enableCallToolSchemaCaching, this.applyElicitationDefaults);
McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures);
@ -556,9 +607,13 @@ public interface McpClient {
private final List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers = new ArrayList<>();
private final List<Function<McpSchema.ElicitationCompleteNotification, Mono<Void>>> elicitationCompleteConsumers = new ArrayList<>();
private Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler;
private Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler;
private Function<ElicitFormRequest, Mono<ElicitResult>> formElicitationHandler;
private Function<ElicitUrlRequest, Mono<ElicitResult>> urlElicitationHandler;
private JsonSchemaValidator jsonSchemaValidator;
@ -683,9 +738,24 @@ public interface McpClient {
* @return This builder instance for method chaining
* @throws IllegalArgumentException if elicitationHandler is null
*/
public AsyncSpec elicitation(Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler) {
public AsyncSpec elicitation(Function<ElicitFormRequest, Mono<ElicitResult>> elicitationHandler) {
Assert.notNull(elicitationHandler, "Elicitation handler must not be null");
this.elicitationHandler = elicitationHandler;
this.formElicitationHandler = elicitationHandler;
return this;
}
/**
* Sets a custom elicitation handler for processing elicitation message requests.
* The elicitation handler can modify or validate messages before they are sent to
* the server, enabling custom processing logic.
* @param elicitationHandler A function that processes elicitation requests and
* returns results. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if elicitationHandler is null
*/
public AsyncSpec urlElicitation(Function<ElicitUrlRequest, Mono<ElicitResult>> elicitationHandler) {
Assert.notNull(elicitationHandler, "Elicitation handler must not be null");
this.urlElicitationHandler = elicitationHandler;
return this;
}
@ -812,6 +882,36 @@ public interface McpClient {
return this;
}
/**
* Adds a consumer to be notified by the server when an URL elicitation is
* complete.
* @param elicitationCompleteConsumer A consumer that receives elicitation
* complete notifications. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if elicitationCompleteConsumer is null
*/
public AsyncSpec elicitationCompleteConsumer(
Function<McpSchema.ElicitationCompleteNotification, Mono<Void>> elicitationCompleteConsumer) {
Assert.notNull(elicitationCompleteConsumer, "Elicitation complete consumer must not be null");
this.elicitationCompleteConsumers.add(elicitationCompleteConsumer);
return this;
}
/**
* Adds multiple consumers to be notified by the server when an URL elicitation is
* complete.
* @param elicitationCompleteConsumers A list of consumers that receives
* elicitation complete notifications. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if elicitationCompleteConsumers is null
*/
public AsyncSpec elicitationCompleteConsumers(
List<Function<McpSchema.ElicitationCompleteNotification, Mono<Void>>> elicitationCompleteConsumers) {
Assert.notNull(elicitationCompleteConsumers, "Elicitation complete consumers must not be null");
this.elicitationCompleteConsumers.addAll(elicitationCompleteConsumers);
return this;
}
/**
* Sets the JSON schema validator to use for validating tool responses against
* output schemas.
@ -863,7 +963,8 @@ public interface McpClient {
new McpClientFeatures.Async(this.clientInfo, this.capabilities, this.roots,
this.toolsChangeConsumers, this.resourcesChangeConsumers, this.resourcesUpdateConsumers,
this.promptsChangeConsumers, this.loggingConsumers, this.progressConsumers,
this.samplingHandler, this.elicitationHandler, this.enableCallToolSchemaCaching,
this.elicitationCompleteConsumers, this.samplingHandler, this.formElicitationHandler,
this.urlElicitationHandler, this.enableCallToolSchemaCaching,
this.applyElicitationDefaults));
}

View file

@ -61,7 +61,7 @@ class McpClientFeatures {
* @param loggingConsumers the logging consumers.
* @param progressConsumers the progress consumers.
* @param samplingHandler the sampling handler.
* @param elicitationHandler the elicitation handler.
* @param formElicitationHandler the elicitation handler.
* @param enableCallToolSchemaCaching whether to enable call tool schema caching.
* @param applyElicitationDefaults whether the client should fill in missing fields of
* an accepted {@code ElicitResult.content} with the {@code default} values declared
@ -74,8 +74,10 @@ class McpClientFeatures {
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers,
List<Function<McpSchema.ElicitationCompleteNotification, Mono<Void>>> elicitationCompleteConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler,
Function<McpSchema.ElicitFormRequest, Mono<McpSchema.ElicitResult>> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, Mono<McpSchema.ElicitResult>> urlElicitationHandler,
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
/**
@ -88,7 +90,7 @@ class McpClientFeatures {
* @param loggingConsumers the logging consumers.
* @param progressConsumers the progress consumers.
* @param samplingHandler the sampling handler.
* @param elicitationHandler the elicitation handler.
* @param formElicitationHandler the elicitation handler.
* @param enableCallToolSchemaCaching whether to enable call tool schema caching.
* @param applyElicitationDefaults whether the client should fill in missing
* fields of an accepted {@code ElicitResult.content} with the {@code default}
@ -102,8 +104,10 @@ class McpClientFeatures {
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
List<Function<McpSchema.ProgressNotification, Mono<Void>>> progressConsumers,
List<Function<McpSchema.ElicitationCompleteNotification, Mono<Void>>> elicitationCompleteConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler,
Function<McpSchema.ElicitFormRequest, Mono<McpSchema.ElicitResult>> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, Mono<McpSchema.ElicitResult>> urlElicitationHandler,
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
Assert.notNull(clientInfo, "Client info must not be null");
@ -112,8 +116,7 @@ class McpClientFeatures {
: new McpSchema.ClientCapabilities(null,
!Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null,
samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null,
elicitationHandler != null ? McpSchema.ClientCapabilities.Elicitation.builder().build()
: null);
elicitationCapabilities(formElicitationHandler, urlElicitationHandler));
this.roots = roots != null ? new ConcurrentHashMap<>(roots) : new ConcurrentHashMap<>();
this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of();
@ -122,8 +125,11 @@ class McpClientFeatures {
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
this.progressConsumers = progressConsumers != null ? progressConsumers : List.of();
this.elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers
: List.of();
this.samplingHandler = samplingHandler;
this.elicitationHandler = elicitationHandler;
this.formElicitationHandler = formElicitationHandler;
this.urlElicitationHandler = urlElicitationHandler;
this.enableCallToolSchemaCaching = enableCallToolSchemaCaching;
this.applyElicitationDefaults = applyElicitationDefaults;
}
@ -139,10 +145,10 @@ class McpClientFeatures {
List<Function<List<McpSchema.Prompt>, Mono<Void>>> promptsChangeConsumers,
List<Function<McpSchema.LoggingMessageNotification, Mono<Void>>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler,
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
Function<McpSchema.ElicitFormRequest, Mono<McpSchema.ElicitResult>> elicitationHandler) {
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler,
elicitationHandler, false, false);
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), List.of(),
samplingHandler, elicitationHandler, null, false, false);
}
/**
@ -190,19 +196,36 @@ class McpClientFeatures {
.subscribeOn(Schedulers.boundedElastic()));
}
List<Function<McpSchema.ElicitationCompleteNotification, Mono<Void>>> elicitationCompleteConsumers = new ArrayList<>();
for (Consumer<McpSchema.ElicitationCompleteNotification> consumer : syncSpec
.elicitationCompleteConsumers()) {
elicitationCompleteConsumers.add(l -> Mono.<Void>fromRunnable(() -> consumer.accept(l))
.subscribeOn(Schedulers.boundedElastic()));
}
Function<McpSchema.CreateMessageRequest, Mono<McpSchema.CreateMessageResult>> samplingHandler = r -> Mono
.fromCallable(() -> syncSpec.samplingHandler().apply(r))
.subscribeOn(Schedulers.boundedElastic());
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler = r -> Mono
.fromCallable(() -> syncSpec.elicitationHandler().apply(r))
.subscribeOn(Schedulers.boundedElastic());
Function<McpSchema.ElicitFormRequest, Mono<McpSchema.ElicitResult>> formElicitationHandler = syncSpec
.formElicitationHandler() != null
? r -> Mono.fromCallable(() -> syncSpec.formElicitationHandler().apply(r))
.subscribeOn(Schedulers.boundedElastic())
: null;
Function<McpSchema.ElicitUrlRequest, Mono<McpSchema.ElicitResult>> urlElicitationHandler = syncSpec
.urlElicitationHandler() != null
? r -> Mono.fromCallable(() -> syncSpec.urlElicitationHandler().apply(r))
.subscribeOn(Schedulers.boundedElastic())
: null;
return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(),
toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers,
loggingConsumers, progressConsumers, samplingHandler, elicitationHandler,
syncSpec.enableCallToolSchemaCaching, syncSpec.applyElicitationDefaults);
loggingConsumers, progressConsumers, elicitationCompleteConsumers, samplingHandler,
formElicitationHandler, urlElicitationHandler, syncSpec.enableCallToolSchemaCaching,
syncSpec.applyElicitationDefaults);
}
}
/**
@ -218,7 +241,7 @@ class McpClientFeatures {
* @param loggingConsumers the logging consumers.
* @param progressConsumers the progress consumers.
* @param samplingHandler the sampling handler.
* @param elicitationHandler the elicitation handler.
* @param formElicitationHandler the elicitation handler.
* @param enableCallToolSchemaCaching whether to enable call tool schema caching.
* @param applyElicitationDefaults whether the client should fill in missing fields of
* an accepted {@code ElicitResult.content} with the {@code default} values declared
@ -231,8 +254,10 @@ class McpClientFeatures {
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
List<Consumer<McpSchema.ProgressNotification>> progressConsumers,
List<Consumer<McpSchema.ElicitationCompleteNotification>> elicitationCompleteConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler,
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, McpSchema.ElicitResult> urlElicitationHandler,
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
/**
@ -247,7 +272,7 @@ class McpClientFeatures {
* @param loggingConsumers the logging consumers.
* @param progressConsumers the progress consumers.
* @param samplingHandler the sampling handler.
* @param elicitationHandler the elicitation handler.
* @param formElicitationHandler the elicitation handler.
* @param enableCallToolSchemaCaching whether to enable call tool schema caching.
* @param applyElicitationDefaults whether the client should fill in missing
* fields of an accepted {@code ElicitResult.content} with the {@code default}
@ -260,8 +285,10 @@ class McpClientFeatures {
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
List<Consumer<McpSchema.ProgressNotification>> progressConsumers,
List<Consumer<McpSchema.ElicitationCompleteNotification>> elicitationCompleteConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler,
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, McpSchema.ElicitResult> urlElicitationHandler,
boolean enableCallToolSchemaCaching, boolean applyElicitationDefaults) {
Assert.notNull(clientInfo, "Client info must not be null");
@ -270,8 +297,7 @@ class McpClientFeatures {
: new McpSchema.ClientCapabilities(null,
!Utils.isEmpty(roots) ? new McpSchema.ClientCapabilities.RootCapabilities(false) : null,
samplingHandler != null ? new McpSchema.ClientCapabilities.Sampling() : null,
elicitationHandler != null ? McpSchema.ClientCapabilities.Elicitation.builder().build()
: null);
elicitationCapabilities(formElicitationHandler, urlElicitationHandler));
this.roots = roots != null ? new HashMap<>(roots) : new HashMap<>();
this.toolsChangeConsumers = toolsChangeConsumers != null ? toolsChangeConsumers : List.of();
@ -280,8 +306,11 @@ class McpClientFeatures {
this.promptsChangeConsumers = promptsChangeConsumers != null ? promptsChangeConsumers : List.of();
this.loggingConsumers = loggingConsumers != null ? loggingConsumers : List.of();
this.progressConsumers = progressConsumers != null ? progressConsumers : List.of();
this.elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers
: List.of();
this.samplingHandler = samplingHandler;
this.elicitationHandler = elicitationHandler;
this.formElicitationHandler = formElicitationHandler;
this.urlElicitationHandler = urlElicitationHandler;
this.enableCallToolSchemaCaching = enableCallToolSchemaCaching;
this.applyElicitationDefaults = applyElicitationDefaults;
}
@ -296,11 +325,29 @@ class McpClientFeatures {
List<Consumer<List<McpSchema.Prompt>>> promptsChangeConsumers,
List<Consumer<McpSchema.LoggingMessageNotification>> loggingConsumers,
Function<McpSchema.CreateMessageRequest, McpSchema.CreateMessageResult> samplingHandler,
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler) {
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, McpSchema.ElicitResult> urlElicitationHandler) {
this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers,
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler,
elicitationHandler, false, false);
resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), List.of(),
samplingHandler, formElicitationHandler, urlElicitationHandler, false, false);
}
}
private static McpSchema.ClientCapabilities.Elicitation elicitationCapabilities(
Function<McpSchema.ElicitFormRequest, ?> formElicitationHandler,
Function<McpSchema.ElicitUrlRequest, ?> urlElicitationHandler) {
McpSchema.ClientCapabilities.Elicitation elicitationCapabilities = null;
if (formElicitationHandler != null || urlElicitationHandler != null) {
var elicitationCapabilitiesBuilder = McpSchema.ClientCapabilities.Elicitation.builder();
if (formElicitationHandler != null) {
elicitationCapabilitiesBuilder.form(new McpSchema.ClientCapabilities.Elicitation.Form());
}
if (urlElicitationHandler != null) {
elicitationCapabilitiesBuilder.url(new McpSchema.ClientCapabilities.Elicitation.Url());
}
elicitationCapabilities = elicitationCapabilitiesBuilder.build();
}
return elicitationCapabilities;
}
}

View file

@ -920,6 +920,25 @@ public class McpAsyncServer {
return this.mcpTransportProvider.notifyClients(McpSchema.METHOD_NOTIFICATION_PROMPTS_LIST_CHANGED, null);
}
/**
* Sends an elicitation complete notification to a specific client session, indicating
* that an out-of-band URL elicitation interaction has completed.
* @param sessionId The ID of the session to notify
* @param notification The notification containing the elicitation ID
* @return A Mono that completes when the notification has been sent
*/
public Mono<Void> sendElicitationComplete(String sessionId,
McpSchema.ElicitationCompleteNotification notification) {
if (sessionId == null) {
return Mono.error(new IllegalArgumentException("Session ID must not be null"));
}
if (notification == null) {
return Mono.error(new IllegalArgumentException("Notification must not be null"));
}
return this.mcpTransportProvider.notifyClient(sessionId, McpSchema.METHOD_NOTIFICATION_ELICITATION_COMPLETE,
notification);
}
private McpRequestHandler<McpSchema.ListPromptsResult> promptsListRequestHandler() {
return (exchange, params) -> {
// TODO: Implement pagination

View file

@ -4,18 +4,16 @@
package io.modelcontextprotocol.server;
import io.modelcontextprotocol.common.McpTransportContext;
import java.util.ArrayList;
import java.util.Collections;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpLoggableSession;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.spec.McpSession;
import io.modelcontextprotocol.util.Assert;
import reactor.core.publisher.Mono;
@ -171,13 +169,27 @@ public class McpAsyncServerExchange {
return Mono
.error(new IllegalStateException("Client must be initialized. Call the initialize method first!"));
}
if (this.clientCapabilities.elicitation() == null) {
McpSchema.ClientCapabilities.Elicitation elicitation = this.clientCapabilities.elicitation();
if (elicitation == null) {
return Mono.error(new IllegalStateException("Client must be configured with elicitation capabilities"));
}
if (this.jsonSchemaValidator != null) {
// elicitation: {} is equivalent to elicitation: { form: {} }
boolean supportsForm = elicitation.form() != null || elicitation.url() == null;
boolean supportsUrl = elicitation.url() != null;
if (elicitRequest instanceof McpSchema.ElicitFormRequest && !supportsForm) {
return Mono
.error(new IllegalStateException("Client must be configured with form elicitation capabilities"));
}
if (elicitRequest instanceof McpSchema.ElicitUrlRequest && !supportsUrl) {
return Mono.error(new IllegalStateException("Client must be configured with URL elicitation capabilities"));
}
if (this.jsonSchemaValidator != null && elicitRequest instanceof McpSchema.ElicitFormRequest formRequest) {
try {
this.jsonSchemaValidator.assertConforms("ElicitRequest requestedSchema",
elicitRequest.requestedSchema());
this.jsonSchemaValidator.assertConforms("ElicitRequest requestedSchema", formRequest.requestedSchema());
}
catch (IllegalArgumentException e) {
return Mono.error(e);

View file

@ -230,6 +230,16 @@ public class McpSyncServer {
this.asyncServer.notifyPromptsListChanged().block();
}
/**
* Sends an elicitation complete notification to a specific client session, indicating
* that an out-of-band URL elicitation interaction has completed.
* @param sessionId The ID of the session to notify
* @param notification The notification containing the elicitation ID
*/
public void sendElicitationComplete(String sessionId, McpSchema.ElicitationCompleteNotification notification) {
this.asyncServer.sendElicitationComplete(sessionId, notification).block();
}
/**
* Close the server gracefully.
*/

View file

@ -1,5 +1,5 @@
/*
* Copyright 2024 - 2024 the original author or authors.
* Copyright 2024 - 2026 the original author or authors.
*/
package io.modelcontextprotocol.spec;
@ -20,6 +20,15 @@ public class McpError extends RuntimeException {
public static final Function<String, McpError> RESOURCE_NOT_FOUND = resourceUri -> new McpError(new JSONRPCError(
McpSchema.ErrorCodes.RESOURCE_NOT_FOUND, "Resource not found", Map.of("uri", resourceUri)));
/**
* <a href=
* "https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation#url-elicitation-required-error">URL
* Elicitation Required</a>
*/
public static final Function<java.util.List<McpSchema.ElicitUrlRequest>, McpError> URL_ELICITATION_REQUIRED = elicitations -> new McpError(
new JSONRPCError(McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED, "URL elicitation required",
Map.of("elicitations", elicitations)));
private JSONRPCError jsonRpcError;
public McpError(JSONRPCError jsonRpcError) {

View file

@ -10,9 +10,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@ -20,10 +17,11 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.util.Assert;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Based on the <a href="http://www.jsonrpc.org/specification">JSON-RPC 2.0
@ -114,6 +112,8 @@ public final class McpSchema {
// Elicitation Methods
public static final String METHOD_ELICITATION_CREATE = "elicitation/create";
public static final String METHOD_NOTIFICATION_ELICITATION_COMPLETE = "notifications/elicitation/complete";
// ---------------------------
// JSON-RPC Error Codes
// ---------------------------
@ -152,6 +152,11 @@ public final class McpSchema {
*/
public static final int RESOURCE_NOT_FOUND = -32002;
/**
* URL elicitation is required before the request can proceed.
*/
public static final int URL_ELICITATION_REQUIRED = -32042;
}
/**
@ -3883,9 +3888,48 @@ public final class McpSchema {
}
// Elicitation
/**
* A request from the server to elicit additional information from the user, either
* through the client or out-of-band.
*
* @see ElicitFormRequest
* @see ElicitUrlRequest
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "mode",
defaultImpl = ElicitFormRequest.class)
@JsonSubTypes({ @JsonSubTypes.Type(value = ElicitFormRequest.class, name = ElicitFormRequest.MODE),
@JsonSubTypes.Type(value = ElicitUrlRequest.class, name = ElicitUrlRequest.MODE) })
public interface ElicitRequest extends Request {
String message();
Map<String, Object> meta();
String mode();
/**
* @deprecated Use {@link ElicitFormRequest#builder(String, Map)} instead.
*/
@Deprecated
static ElicitFormRequest.Builder builder() {
return new ElicitFormRequest.Builder();
}
/**
* @deprecated Use {@link ElicitFormRequest#builder(String, Map)} instead.
*/
@Deprecated
static ElicitFormRequest.Builder builder(String message, Map<String, Object> requestedSchema) {
return new ElicitFormRequest.Builder(message, requestedSchema);
}
}
/**
* A request from the server to elicit additional information from the user via the
* client.
* client, using {@code form} mode.
*
* @param message The message to present to the user
* @param requestedSchema A restricted subset of JSON Schema. Only top-level
@ -3901,18 +3945,26 @@ public final class McpSchema {
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record ElicitRequest( // @formatter:off
public record ElicitFormRequest( // @formatter:off
@JsonProperty("message") String message,
@JsonProperty("requestedSchema") Map<String, Object> requestedSchema,
@JsonProperty("_meta") Map<String, Object> meta) implements Request { // @formatter:on
@JsonProperty("_meta") Map<String, Object> meta) implements ElicitRequest { // @formatter:on
public ElicitRequest {
public static final String MODE = "form";
public ElicitFormRequest {
Assert.notNull(message, "message must not be null");
Assert.notNull(requestedSchema, "requestedSchema must not be null");
}
@Override
@JsonProperty("mode")
public String mode() {
return MODE;
}
@JsonCreator
static ElicitRequest fromJson(@JsonProperty("message") String message,
static ElicitFormRequest fromJson(@JsonProperty("message") String message,
@JsonProperty("requestedSchema") Map<String, Object> requestedSchema,
@JsonProperty("_meta") Map<String, Object> meta) {
if (message == null || requestedSchema == null) {
@ -3925,23 +3977,10 @@ public final class McpSchema {
missing.add("requestedSchema -> {}");
requestedSchema = Map.of();
}
logger.warn("ElicitRequest: missing required fields during deserialization: {}",
logger.warn("ElicitFormRequest: missing required fields during deserialization: {}",
String.join(", ", missing));
}
return new ElicitRequest(message, requestedSchema, meta);
}
// backwards compatibility constructor
public ElicitRequest(String message, Map<String, Object> requestedSchema) {
this(message, requestedSchema, null);
}
/**
* @deprecated Use {@link #builder(String, Map)} instead.
*/
@Deprecated
public static Builder builder() {
return new Builder();
return new ElicitFormRequest(message, requestedSchema, meta);
}
public static Builder builder(String message, Map<String, Object> requestedSchema) {
@ -3957,11 +3996,11 @@ public final class McpSchema {
private Map<String, Object> meta;
/**
* @deprecated Use {@link ElicitRequest#builder(String, Map)} factory method
* instead.
* @deprecated Use {@link ElicitFormRequest#builder(String, Map)} factory
* method instead.
*/
@Deprecated
public Builder() {
private Builder() {
}
private Builder(String message, Map<String, Object> requestedSchema) {
@ -3971,12 +4010,22 @@ public final class McpSchema {
this.requestedSchema = requestedSchema;
}
/**
* @deprecated Use {@link ElicitFormRequest#builder(String, Map)} factory
* method instead.
*/
@Deprecated
public Builder message(String message) {
Assert.notNull(message, "message must not be null");
this.message = message;
return this;
}
/**
* @deprecated Use {@link ElicitFormRequest#builder(String, Map)} factory
* method instead.
*/
@Deprecated
public Builder requestedSchema(Map<String, Object> requestedSchema) {
Assert.notNull(requestedSchema, "requestedSchema must not be null");
this.requestedSchema = requestedSchema;
@ -3996,10 +4045,114 @@ public final class McpSchema {
return this;
}
public ElicitRequest build() {
public ElicitFormRequest build() {
Assert.notNull(message, "message must not be null");
Assert.notNull(requestedSchema, "requestedSchema must not be null");
return new ElicitRequest(message, requestedSchema, meta);
return new ElicitFormRequest(message, requestedSchema, meta);
}
}
}
/**
* A request from the server to elicit additional information from the user out of
* band, using {@code url} mode.
*
* @param message The message to present to the user
* @param url The URL the user must navigate to.
* @param elicitationId The elicitation ID of the elicitations reques.t
* @param meta See specification for notes on _meta usage
* <p>
* Note: {@code message}, {@code url} and {@code elicitationId} are required by the
* MCP specification. Deserialization accepts missing values and substitutes defaults
* to avoid breaking existing integrations that may omit these fields.
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record ElicitUrlRequest( // @formatter:off
@JsonProperty("message") String message,
@JsonProperty("url") String url,
@JsonProperty("elicitationId") String elicitationId,
@JsonProperty("_meta") Map<String, Object> meta) implements ElicitRequest { // @formatter:on
public static final String MODE = "url";
public ElicitUrlRequest {
Assert.notNull(message, "message must not be null");
Assert.notNull(url, "url must not be null");
Assert.notNull(elicitationId, "elicitationId must not be null");
}
@Override
@JsonProperty("mode")
public String mode() {
return MODE;
}
@JsonCreator
static ElicitUrlRequest fromJson(@JsonProperty("message") String message, @JsonProperty("url") String url,
@JsonProperty("elicitationId") String elicitationId, @JsonProperty("_meta") Map<String, Object> meta) {
if (message == null || url == null || elicitationId == null) {
List<String> missing = new ArrayList<>();
if (message == null) {
missing.add("message -> ''");
message = "";
}
if (url == null) {
missing.add("url -> ''");
url = "";
}
if (elicitationId == null) {
missing.add("elicitationId -> ''");
elicitationId = "";
}
logger.warn("ElicitUrlRequest: missing required fields during deserialization: {}",
String.join(", ", missing));
}
return new ElicitUrlRequest(message, url, elicitationId, meta);
}
public static Builder builder(String message, String url, String elicitationId) {
return new Builder(message, url, elicitationId);
}
public static class Builder {
private final String message;
private final String url;
private final String elicitationId;
private Map<String, Object> meta;
private Builder(String message, String url, String elicitationId) {
Assert.notNull(message, "message must not be null");
Assert.notNull(url, "url must not be null");
Assert.notNull(elicitationId, "elicitationId must not be null");
this.message = message;
this.url = url;
this.elicitationId = elicitationId;
}
public Builder meta(Map<String, Object> meta) {
this.meta = meta;
return this;
}
public Builder progressToken(Object progressToken) {
if (this.meta == null) {
this.meta = new HashMap<>();
}
this.meta.put("progressToken", progressToken);
return this;
}
public ElicitUrlRequest build() {
Assert.notNull(message, "message must not be null");
Assert.notNull(url, "url must not be null");
Assert.notNull(elicitationId, "elicitationId must not be null");
return new ElicitUrlRequest(message, url, elicitationId, meta);
}
}
@ -4103,6 +4256,39 @@ public final class McpSchema {
}
}
/**
* A notification from the server to the client indicating that an out-of-band URL
* elicitation interaction has completed.
*
* @param elicitationId The unique identifier of the completed elicitation
* @param meta See specification for notes on _meta usage
*/
@JsonInclude(JsonInclude.Include.NON_ABSENT)
@JsonIgnoreProperties(ignoreUnknown = true)
public record ElicitationCompleteNotification( // @formatter:off
@JsonProperty("elicitationId") String elicitationId,
@JsonProperty("_meta") Map<String, Object> meta) implements Notification { // @formatter:on
public ElicitationCompleteNotification {
Assert.notNull(elicitationId, "elicitationId must not be null");
}
@JsonCreator
static ElicitationCompleteNotification fromJson(@JsonProperty("elicitationId") String elicitationId,
@JsonProperty("_meta") Map<String, Object> meta) {
if (elicitationId == null || elicitationId.isBlank()) {
logger.warn(
"ElicitationCompleteNotification: missing required field 'elicitationId' during deserialization, using default ''");
elicitationId = "";
}
return new ElicitationCompleteNotification(elicitationId, meta);
}
public ElicitationCompleteNotification(String elicitationId) {
this(elicitationId, null);
}
}
// ---------------------------
// Pagination Interfaces
// ---------------------------

View file

@ -0,0 +1,182 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client;
import java.util.List;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* @author Daniel Garnier-Moiroux
*/
class McpAsyncClientTest {
@Nested
class ClientBuilder {
@Nested
class ElicitationHandlers {
@Test
void formElicitationMissingHandler() {
McpClientTransport transport = mock(McpClientTransport.class);
var clientBuilder = McpClient.async(transport)
.capabilities(McpSchema.ClientCapabilities.builder().elicitation().build())
.jsonSchemaValidator(mock(JsonSchemaValidator.class));
var clientBuilderExplicitFormElicitation = McpClient.async(transport)
.capabilities(McpSchema.ClientCapabilities.builder().elicitation(true, false).build())
.jsonSchemaValidator(mock(JsonSchemaValidator.class));
var clientBuilderUrlElicitation = McpClient.async(transport)
.capabilities(McpSchema.ClientCapabilities.builder().elicitation(true, true).build())
.urlElicitation(req -> Mono.empty())
.jsonSchemaValidator(mock(JsonSchemaValidator.class));
assertThatThrownBy(clientBuilder::build).isInstanceOf(IllegalArgumentException.class)
.hasMessage(
"Form elicitation handler must not be null when client capabilities include form elicitation");
assertThatThrownBy(clientBuilderExplicitFormElicitation::build)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage(
"Form elicitation handler must not be null when client capabilities include form elicitation");
assertThatThrownBy(clientBuilderUrlElicitation::build).isInstanceOf(IllegalArgumentException.class)
.hasMessage(
"Form elicitation handler must not be null when client capabilities include form elicitation");
}
@Test
void formElicitationHandlerPresent() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
McpClient.AsyncSpec asyncSpec = McpClient.async(transport)
.capabilities(McpSchema.ClientCapabilities.builder().elicitation(true, false).build());
var clientBuilder = asyncSpec.elicitation(request -> Mono.empty())
.jsonSchemaValidator(mock(JsonSchemaValidator.class));
assertThatCode(clientBuilder::build).doesNotThrowAnyException();
}
@Test
void urlElicitationMissingHandler() {
var clientBuilder = McpClient.async(mock(McpClientTransport.class))
.capabilities(McpSchema.ClientCapabilities.builder().elicitation(false, true).build())
.jsonSchemaValidator(mock(JsonSchemaValidator.class));
assertThatThrownBy(clientBuilder::build).isInstanceOf(IllegalArgumentException.class)
.hasMessage(
"URL elicitation handler must not be null when client capabilities include URL elicitation");
}
@Test
void urlElicitationHandlerPresent() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
var clientBuilder = McpClient.async(transport)
.capabilities(McpSchema.ClientCapabilities.builder().elicitation(false, true).build())
.urlElicitation(request -> Mono.empty())
.jsonSchemaValidator(mock(JsonSchemaValidator.class));
assertThatCode(clientBuilder::build).doesNotThrowAnyException();
}
@Test
void bothHandlersPresent() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
McpClient.AsyncSpec asyncSpec = McpClient.async(transport)
.capabilities(McpSchema.ClientCapabilities.builder().elicitation().build());
var clientBuilder = asyncSpec.elicitation(request1 -> Mono.empty())
.urlElicitation(request -> Mono.empty())
.jsonSchemaValidator(mock(JsonSchemaValidator.class));
assertThatCode(clientBuilder::build).doesNotThrowAnyException();
}
}
@Nested
class ClientCapabilities {
@Test
void noElicitation() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
var client = McpClient.async(transport).jsonSchemaValidator(mock(JsonSchemaValidator.class)).build();
assertThat(client.getClientCapabilities().elicitation()).isNull();
}
@Test
void formElicitationFromHandler() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
McpClient.AsyncSpec asyncSpec = McpClient.async(transport);
var client = asyncSpec.elicitation(req -> Mono.empty())
.jsonSchemaValidator(mock(JsonSchemaValidator.class))
.build();
assertThat(client.getClientCapabilities().elicitation()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().form()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().url()).isNull();
}
@Test
void urlElicitationFromHandler() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
var client = McpClient.async(transport)
.urlElicitation(req -> Mono.empty())
.jsonSchemaValidator(mock(JsonSchemaValidator.class))
.build();
assertThat(client.getClientCapabilities().elicitation()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().form()).isNull();
assertThat(client.getClientCapabilities().elicitation().url()).isNotNull();
}
@Test
void elicitationFromHandlers() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
McpClient.AsyncSpec asyncSpec = McpClient.async(transport);
var client = asyncSpec.elicitation(req -> Mono.empty())
.urlElicitation(req -> Mono.empty())
.jsonSchemaValidator(mock(JsonSchemaValidator.class))
.build();
assertThat(client.getClientCapabilities().elicitation()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().form()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().url()).isNotNull();
}
@Test
void noElicitationFromCapabilities() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
McpClient.AsyncSpec asyncSpec = McpClient.async(transport)
.capabilities(McpSchema.ClientCapabilities.builder().build());
var client = asyncSpec.elicitation(req -> Mono.empty())
.urlElicitation(req -> Mono.empty())
.jsonSchemaValidator(mock(JsonSchemaValidator.class))
.build();
assertThat(client.getClientCapabilities().elicitation()).isNull();
}
}
}
}

View file

@ -0,0 +1,94 @@
/*
* Copyright 2026-2026 the original author or authors.
*/
package io.modelcontextprotocol.client;
import java.util.List;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* @author Daniel Garnier-Moiroux
*/
class McpSyncClientTest {
@Nested
class ClientCapabilities {
@Test
void noElicitation() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
var client = McpClient.sync(transport).jsonSchemaValidator(mock(JsonSchemaValidator.class)).build();
assertThat(client.getClientCapabilities().elicitation()).isNull();
}
@Test
void formElicitationFromHandler() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
var asyncSpec = McpClient.sync(transport);
var client = asyncSpec.elicitation(req -> null)
.jsonSchemaValidator(mock(JsonSchemaValidator.class))
.build();
assertThat(client.getClientCapabilities().elicitation()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().form()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().url()).isNull();
}
@Test
void urlElicitationFromHandler() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
var client = McpClient.sync(transport)
.urlElicitation(req -> null)
.jsonSchemaValidator(mock(JsonSchemaValidator.class))
.build();
assertThat(client.getClientCapabilities().elicitation()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().form()).isNull();
assertThat(client.getClientCapabilities().elicitation().url()).isNotNull();
}
@Test
void elicitationFromHandlers() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
var asyncSpec = McpClient.sync(transport);
var client = asyncSpec.elicitation(req -> null)
.urlElicitation(req -> null)
.jsonSchemaValidator(mock(JsonSchemaValidator.class))
.build();
assertThat(client.getClientCapabilities().elicitation()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().form()).isNotNull();
assertThat(client.getClientCapabilities().elicitation().url()).isNotNull();
}
@Test
void noElicitationFromCapabilities() {
McpClientTransport transport = mock(McpClientTransport.class);
when(transport.protocolVersions()).thenReturn(List.of("2024-11-05"));
var asyncSpec = McpClient.sync(transport).capabilities(McpSchema.ClientCapabilities.builder().build());
var client = asyncSpec.elicitation(req -> null)
.urlElicitation(req -> null)
.jsonSchemaValidator(mock(JsonSchemaValidator.class))
.build();
assertThat(client.getClientCapabilities().elicitation()).isNull();
}
}
}

View file

@ -4,17 +4,17 @@
package io.modelcontextprotocol.server;
import io.modelcontextprotocol.common.McpTransportContext;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.json.TypeRef;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
@ -531,6 +531,149 @@ class McpAsyncServerExchangeTests {
any(TypeRef.class));
}
@Test
void testCreateElicitationWithUrlRequest() {
McpSchema.ClientCapabilities capabilitiesWithUrlElicitation = McpSchema.ClientCapabilities.builder()
.elicitation(false, true)
.build();
McpAsyncServerExchange exchangeWithElicitation = new McpAsyncServerExchange("testSessionId", mockSession,
capabilitiesWithUrlElicitation, clientInfo, McpTransportContext.EMPTY);
McpSchema.ElicitUrlRequest elicitUrlRequest = McpSchema.ElicitUrlRequest
.builder("Please authenticate via URL", "https://example.com/auth", "elicit-url-123")
.build();
McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT)
.build();
when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitUrlRequest), any(TypeRef.class)))
.thenReturn(Mono.just(expectedResult));
StepVerifier.create(exchangeWithElicitation.createElicitation(elicitUrlRequest)).assertNext(result -> {
assertThat(result).isEqualTo(expectedResult);
assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
}).verifyComplete();
}
@Test
void testCreateElicitationWithUrlRequestBypassesValidator() {
McpSchema.ClientCapabilities capabilitiesWithElicitation = McpSchema.ClientCapabilities.builder()
.elicitation(false, true)
.build();
JsonSchemaValidator rejectingValidator = new JsonSchemaValidator() {
@Override
public ValidationResponse validate(Map<String, Object> schema, Object content) {
return ValidationResponse.asInvalid("should not be called");
}
@Override
public ValidationResponse validateSchema(Map<String, Object> schema) {
return ValidationResponse.asInvalid("should not be called");
}
};
McpAsyncServerExchange exchangeWithValidator = new McpAsyncServerExchange("testSessionId", mockSession,
capabilitiesWithElicitation, clientInfo, McpTransportContext.EMPTY, rejectingValidator);
McpSchema.ElicitUrlRequest elicitUrlRequest = McpSchema.ElicitUrlRequest
.builder("Please visit the URL", "https://example.com/oauth", "elicit-oauth-123")
.build();
when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitUrlRequest), any(TypeRef.class)))
.thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build()));
StepVerifier.create(exchangeWithValidator.createElicitation(elicitUrlRequest)).assertNext(result -> {
assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
}).verifyComplete();
verify(mockSession, times(1)).sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(elicitUrlRequest),
any(TypeRef.class));
}
@Test
void testElicitationCapabilitiesEmptyObject() {
McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder().elicitation().build();
McpAsyncServerExchange exchangeEmpty = new McpAsyncServerExchange("testSessionId", mockSession, capabilities,
clientInfo, McpTransportContext.EMPTY);
McpSchema.ElicitFormRequest formRequest = McpSchema.ElicitRequest.builder("form", Map.of("type", "object"))
.build();
McpSchema.ElicitUrlRequest urlRequest = McpSchema.ElicitUrlRequest.builder("url", "http", "123").build();
when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(formRequest), any(TypeRef.class)))
.thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build()));
StepVerifier.create(exchangeEmpty.createElicitation(formRequest)).expectNextCount(1).verifyComplete();
StepVerifier.create(exchangeEmpty.createElicitation(urlRequest))
.verifyErrorSatisfies(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
.hasMessage("Client must be configured with URL elicitation capabilities"));
}
@Test
void testElicitationCapabilitiesFormOnly() {
McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder()
.elicitation(true, false)
.build();
McpAsyncServerExchange exchangeForm = new McpAsyncServerExchange("testSessionId", mockSession, capabilities,
clientInfo, McpTransportContext.EMPTY);
McpSchema.ElicitFormRequest formRequest = McpSchema.ElicitRequest.builder("form", Map.of("type", "object"))
.build();
McpSchema.ElicitUrlRequest urlRequest = McpSchema.ElicitUrlRequest.builder("url", "http", "123").build();
when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(formRequest), any(TypeRef.class)))
.thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build()));
StepVerifier.create(exchangeForm.createElicitation(formRequest)).expectNextCount(1).verifyComplete();
StepVerifier.create(exchangeForm.createElicitation(urlRequest))
.verifyErrorSatisfies(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
.hasMessage("Client must be configured with URL elicitation capabilities"));
}
@Test
void testElicitationCapabilitiesUrlOnly() {
McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder()
.elicitation(false, true)
.build();
McpAsyncServerExchange exchangeUrl = new McpAsyncServerExchange("testSessionId", mockSession, capabilities,
clientInfo, McpTransportContext.EMPTY);
McpSchema.ElicitFormRequest formRequest = McpSchema.ElicitRequest.builder("form", Map.of("type", "object"))
.build();
McpSchema.ElicitUrlRequest urlRequest = McpSchema.ElicitUrlRequest.builder("url", "http", "123").build();
when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(urlRequest), any(TypeRef.class)))
.thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build()));
StepVerifier.create(exchangeUrl.createElicitation(urlRequest)).expectNextCount(1).verifyComplete();
StepVerifier.create(exchangeUrl.createElicitation(formRequest))
.verifyErrorSatisfies(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
.hasMessage("Client must be configured with form elicitation capabilities"));
}
@Test
void testElicitationCapabilitiesBoth() {
McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder()
.elicitation(true, true)
.build();
McpAsyncServerExchange exchangeBoth = new McpAsyncServerExchange("testSessionId", mockSession, capabilities,
clientInfo, McpTransportContext.EMPTY);
McpSchema.ElicitFormRequest formRequest = McpSchema.ElicitRequest.builder("form", Map.of("type", "object"))
.build();
McpSchema.ElicitUrlRequest urlRequest = McpSchema.ElicitUrlRequest.builder("url", "http", "123").build();
when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(formRequest), any(TypeRef.class)))
.thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build()));
when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(urlRequest), any(TypeRef.class)))
.thenReturn(Mono.just(McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build()));
StepVerifier.create(exchangeBoth.createElicitation(formRequest)).expectNextCount(1).verifyComplete();
StepVerifier.create(exchangeBoth.createElicitation(urlRequest)).expectNextCount(1).verifyComplete();
}
// ---------------------------------------
// Create Message Tests
// ---------------------------------------