From 869d1b317708a94527fa86885724e06142a937a4 Mon Sep 17 00:00:00 2001 From: Sainath Reddy Bobbala Date: Tue, 5 May 2026 21:10:32 +0000 Subject: [PATCH] feat(core): add URL elicitation support (SEP-1036) Add URL-type elicitation schema support allowing servers to request URL input from users during tool execution. This enables out-of-band interactions like OAuth flows, payment processing, and API key entry. Schema changes (following CONTRIBUTING.md rules): - ElicitRequest: append mode, url, elicitationId at end of record components (new optional fields default to null) - ElicitationCompleteNotification: new record implementing Notification - ErrorCodes.URL_ELICITATION_REQUIRED (-32042): new error code - METHOD_NOTIFICATION_ELICITATION_COMPLETE: new method constant Server exchange: - createElicitation validates URL mode against client capability - sendElicitationComplete sends completion notification to client Client: - elicitationCompleteConsumer() builder method on SyncSpec and AsyncSpec - Notification handler dispatches to registered consumers - Existing elicitation handler naturally receives URL mode requests Validation: - Canonical constructor enforces url/elicitationId non-null for URL mode - Builder rejects requestedSchema when mode is 'url' Backward compatible: existing form-mode callers are unaffected. Old constructors delegate to canonical constructor with null for new fields. Ref #939 --- .../client/McpAsyncClient.java | 32 +++- .../client/McpClient.java | 58 +++++-- .../client/McpClientFeatures.java | 47 +++--- .../server/McpAsyncServerExchange.java | 27 +++- .../server/McpSyncServerExchange.java | 10 ++ .../modelcontextprotocol/spec/McpSchema.java | 140 +++++++++++++++-- .../server/McpAsyncServerExchangeTests.java | 103 +++++++++++-- .../spec/McpSchemaTests.java | 142 +++++++++++++++++- 8 files changed, 489 insertions(+), 70 deletions(-) diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java index f984426c7..fddbeb7d8 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java @@ -15,6 +15,9 @@ 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; @@ -30,16 +33,14 @@ import io.modelcontextprotocol.spec.McpSchema.ElicitResult; import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; -import io.modelcontextprotocol.util.ToolNameValidator; import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult; import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; import io.modelcontextprotocol.spec.McpSchema.PaginatedRequest; 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; @@ -107,6 +108,9 @@ public class McpAsyncClient { public static final TypeRef PROGRESS_NOTIFICATION_TYPE_REF = new TypeRef<>() { }; + public static final TypeRef ELICITATION_COMPLETE_NOTIFICATION_TYPE_REF = new TypeRef<>() { + }; + public static final String NEGOTIATED_PROTOCOL_VERSION = "io.modelcontextprotocol.client.negotiated-protocol-version"; /** @@ -297,6 +301,16 @@ public class McpAsyncClient { notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROGRESS, asyncProgressNotificationHandler(progressConsumersFinal)); + // Elicitation Complete Notification + List>> 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> postInitializationHook = init -> { if (init.initializeResult().capabilities().tools() == null || !enableCallToolSchemaCaching) { @@ -1037,6 +1051,18 @@ private NotificationHandler asyncProgressNotificationHandler( }; } + private NotificationHandler asyncElicitationCompleteNotificationHandler( + List>> elicitationCompleteConsumers) { + return params -> { + McpSchema.ElicitationCompleteNotification notification = transport.unmarshalFrom(params, + ELICITATION_COMPLETE_NOTIFICATION_TYPE_REF); + + return Flux.fromIterable(elicitationCompleteConsumers) + .flatMap(consumer -> consumer.apply(notification)) + .then(); + }; + } + /** * This method is package-private and used for test only. Should not be called by user * code. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java index 2bba792d5..15bf300bd 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java @@ -4,6 +4,15 @@ package io.modelcontextprotocol.client; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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; @@ -20,15 +29,6 @@ import io.modelcontextprotocol.util.Assert; import reactor.core.publisher.Mono; -import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.function.Supplier; - /** * 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 @@ -189,6 +189,8 @@ class SyncSpec { private Function elicitationHandler; + private final List> elicitationCompleteConsumers = new ArrayList<>(); + private Supplier contextProvider = () -> McpTransportContext.EMPTY; private JsonSchemaValidator jsonSchemaValidator; @@ -318,6 +320,21 @@ public SyncSpec elicitation(Function elicitationHan return this; } + /** + * Adds a consumer to be notified when an elicitation complete notification is + * received from the server. This allows the client to react when an out-of-band + * URL elicitation interaction has completed. + * @param consumer A consumer that receives the elicitation complete notification. + * Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if consumer is null + */ + public SyncSpec elicitationCompleteConsumer(Consumer consumer) { + Assert.notNull(consumer, "Elicitation complete consumer must not be null"); + this.elicitationCompleteConsumers.add(consumer); + return this; + } + /** * Adds a consumer to be notified when the available tools change. This allows the * client to react to changes in the server's tool capabilities, such as tools @@ -488,7 +505,7 @@ 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.elicitationHandler, this.enableCallToolSchemaCaching, this.elicitationCompleteConsumers); McpClientFeatures.Async asyncFeatures = McpClientFeatures.Async.fromSync(syncFeatures); @@ -545,6 +562,8 @@ class AsyncSpec { private Function> elicitationHandler; + private final List>> elicitationCompleteConsumers = new ArrayList<>(); + private JsonSchemaValidator jsonSchemaValidator; private boolean enableCallToolSchemaCaching = false; // Default to false @@ -672,6 +691,22 @@ public AsyncSpec elicitation(Function> elicita return this; } + /** + * Adds a consumer to be notified when an elicitation complete notification is + * received from the server. This allows the client to react when an out-of-band + * URL elicitation interaction has completed. + * @param consumer A function that receives the elicitation complete notification + * and returns a Mono signaling completion. Must not be null. + * @return This builder instance for method chaining + * @throws IllegalArgumentException if consumer is null + */ + public AsyncSpec elicitationCompleteConsumer( + Function> consumer) { + Assert.notNull(consumer, "Elicitation complete consumer must not be null"); + this.elicitationCompleteConsumers.add(consumer); + return this; + } + /** * Adds a consumer to be notified when the available tools change. This allows the * client to react to changes in the server's tool capabilities, such as tools @@ -833,7 +868,8 @@ public McpAsyncClient build() { 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.samplingHandler, this.elicitationHandler, this.enableCallToolSchemaCaching, + this.elicitationCompleteConsumers)); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java index fcf3b7263..03c372613 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpClientFeatures.java @@ -63,6 +63,8 @@ class McpClientFeatures { * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param elicitationCompleteConsumers the elicitation complete notification + * consumers. */ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List, Mono>> toolsChangeConsumers, @@ -73,7 +75,8 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c List>> progressConsumers, Function> samplingHandler, Function> elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, + List>> elicitationCompleteConsumers) { /** * Create an instance and validate the arguments. @@ -87,6 +90,8 @@ record Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param elicitationCompleteConsumers the elicitation complete notification + * consumers. */ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, @@ -98,7 +103,8 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c List>> progressConsumers, Function> samplingHandler, Function> elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, + List>> elicitationCompleteConsumers) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -119,6 +125,8 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c this.samplingHandler = samplingHandler; this.elicitationHandler = elicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; + this.elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers + : List.of(); } /** @@ -135,7 +143,7 @@ public Async(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities c Function> elicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler, - elicitationHandler, false); + elicitationHandler, false, List.of()); } /** @@ -191,10 +199,17 @@ public static Async fromSync(Sync syncSpec) { .fromCallable(() -> syncSpec.elicitationHandler().apply(r)) .subscribeOn(Schedulers.boundedElastic()); + List>> elicitationCompleteConsumers = new ArrayList<>(); + for (Consumer consumer : syncSpec + .elicitationCompleteConsumers()) { + elicitationCompleteConsumers.add(n -> Mono.fromRunnable(() -> consumer.accept(n)) + .subscribeOn(Schedulers.boundedElastic())); + } + return new Async(syncSpec.clientInfo(), syncSpec.clientCapabilities(), syncSpec.roots(), toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, progressConsumers, samplingHandler, elicitationHandler, - syncSpec.enableCallToolSchemaCaching); + syncSpec.enableCallToolSchemaCaching(), elicitationCompleteConsumers); } } @@ -213,6 +228,8 @@ public static Async fromSync(Sync syncSpec) { * @param samplingHandler the sampling handler. * @param elicitationHandler the elicitation handler. * @param enableCallToolSchemaCaching whether to enable call tool schema caching. + * @param elicitationCompleteConsumers the elicitation complete notification + * consumers. */ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List>> toolsChangeConsumers, @@ -223,22 +240,11 @@ public record Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabili List> progressConsumers, Function samplingHandler, Function elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, + List> elicitationCompleteConsumers) { /** * Create an instance and validate the arguments. - * @param clientInfo the client implementation information. - * @param clientCapabilities the client capabilities. - * @param roots the roots. - * @param toolsChangeConsumers the tools change consumers. - * @param resourcesChangeConsumers the resources change consumers. - * @param resourcesUpdateConsumers the resource update consumers. - * @param promptsChangeConsumers the prompts change consumers. - * @param loggingConsumers the logging consumers. - * @param progressConsumers the progress consumers. - * @param samplingHandler the sampling handler. - * @param elicitationHandler the elicitation handler. - * @param enableCallToolSchemaCaching whether to enable call tool schema caching. */ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities clientCapabilities, Map roots, List>> toolsChangeConsumers, @@ -249,7 +255,8 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl List> progressConsumers, Function samplingHandler, Function elicitationHandler, - boolean enableCallToolSchemaCaching) { + boolean enableCallToolSchemaCaching, + List> elicitationCompleteConsumers) { Assert.notNull(clientInfo, "Client info must not be null"); this.clientInfo = clientInfo; @@ -270,6 +277,8 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl this.samplingHandler = samplingHandler; this.elicitationHandler = elicitationHandler; this.enableCallToolSchemaCaching = enableCallToolSchemaCaching; + this.elicitationCompleteConsumers = elicitationCompleteConsumers != null ? elicitationCompleteConsumers + : List.of(); } /** @@ -285,7 +294,7 @@ public Sync(McpSchema.Implementation clientInfo, McpSchema.ClientCapabilities cl Function elicitationHandler) { this(clientInfo, clientCapabilities, roots, toolsChangeConsumers, resourcesChangeConsumers, resourcesUpdateConsumers, promptsChangeConsumers, loggingConsumers, List.of(), samplingHandler, - elicitationHandler, false); + elicitationHandler, false, List.of()); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java index aaa643362..71a610f8e 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpAsyncServerExchange.java @@ -4,17 +4,15 @@ 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.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; @@ -152,10 +150,33 @@ public Mono createElicitation(McpSchema.ElicitRequest el if (this.clientCapabilities.elicitation() == null) { return Mono.error(new IllegalStateException("Client must be configured with elicitation capabilities")); } + if ("url".equals(elicitRequest.mode()) && this.clientCapabilities.elicitation().url() == null) { + return Mono.error(new IllegalStateException( + "Client must be configured with URL elicitation capabilities to handle URL mode requests")); + } return this.session.sendRequest(McpSchema.METHOD_ELICITATION_CREATE, elicitRequest, ELICITATION_RESULT_TYPE_REF); } + /** + * Sends an elicitation complete notification to the client, indicating that an + * out-of-band URL elicitation interaction has completed. + * @param notification The notification containing the elicitation ID + * @return A Mono that completes when the notification has been sent. + * @see McpSchema.ElicitationCompleteNotification + */ + public Mono sendElicitationComplete(McpSchema.ElicitationCompleteNotification notification) { + if (this.clientCapabilities == null) { + return Mono + .error(new IllegalStateException("Client must be initialized. Call the initialize method first!")); + } + if (this.clientCapabilities.elicitation() == null || this.clientCapabilities.elicitation().url() == null) { + return Mono.error(new IllegalStateException( + "Client must be configured with URL elicitation capabilities to receive elicitation complete notifications")); + } + return this.session.sendNotification(McpSchema.METHOD_NOTIFICATION_ELICITATION_COMPLETE, notification); + } + /** * Retrieves the list of all roots provided by the client. * @return A Mono that emits the list of roots result. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java index 0b9115b79..5828f5863 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/McpSyncServerExchange.java @@ -100,6 +100,16 @@ public McpSchema.ElicitResult createElicitation(McpSchema.ElicitRequest elicitRe return this.exchange.createElicitation(elicitRequest).block(); } + /** + * Sends an elicitation complete notification to the client, indicating that an + * out-of-band URL elicitation interaction has completed. + * @param notification The notification containing the elicitation ID + * @see McpSchema.ElicitationCompleteNotification + */ + public void sendElicitationComplete(McpSchema.ElicitationCompleteNotification notification) { + this.exchange.sendElicitationComplete(notification).block(); + } + /** * Retrieves the list of all roots provided by the client. * @return The list of roots result. diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java index 6c7f56848..6424b1162 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/spec/McpSchema.java @@ -10,6 +10,9 @@ 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; @@ -17,11 +20,10 @@ 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 JSON-RPC 2.0 @@ -106,6 +108,8 @@ private 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 // --------------------------- @@ -144,6 +148,11 @@ public static final class ErrorCodes { */ 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; + } /** @@ -3690,50 +3699,78 @@ public CreateMessageResult build() { * A request from the server to elicit additional information from the user via the * client. * + *

+ * Elicitation supports two modes: + *

    + *
  • Form mode ({@code mode} is "form" or {@code null}): collects structured + * data via {@code requestedSchema}.
  • + *
  • URL mode ({@code mode} is "url"): directs the user to an external URL + * for out-of-band interaction (e.g., OAuth, payment).
  • + *
+ * * @param message The message to present to the user * @param requestedSchema A restricted subset of JSON Schema. Only top-level - * properties are allowed, without nesting + * properties are allowed, without nesting. Required for form mode. * @param meta See specification for notes on _meta usage + * @param mode The elicitation mode: "form" (default) or "url" + * @param url The URL to present to the user (required for URL mode) + * @param elicitationId A unique identifier for this elicitation (required for URL + * mode) *

* Note: {@code message} and {@code requestedSchema} are required by the MCP - * specification. Deserialization accepts missing values and substitutes defaults to - * avoid breaking existing integrations that may omit these fields. + * specification for form mode. 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 ElicitRequest( // @formatter:off @JsonProperty("message") String message, @JsonProperty("requestedSchema") Map requestedSchema, - @JsonProperty("_meta") Map meta) implements Request { // @formatter:on + @JsonProperty("_meta") Map meta, + @JsonProperty("mode") String mode, + @JsonProperty("url") String url, + @JsonProperty("elicitationId") String elicitationId) implements Request { // @formatter:on public ElicitRequest { Assert.notNull(message, "message must not be null"); - Assert.notNull(requestedSchema, "requestedSchema must not be null"); + if ("url".equals(mode)) { + Assert.notNull(url, "url must not be null when mode is 'url'"); + Assert.notNull(elicitationId, "elicitationId must not be null when mode is 'url'"); + } + else { + Assert.notNull(requestedSchema, "requestedSchema must not be null for form mode"); + } } @JsonCreator static ElicitRequest fromJson(@JsonProperty("message") String message, @JsonProperty("requestedSchema") Map requestedSchema, - @JsonProperty("_meta") Map meta) { - if (message == null || requestedSchema == null) { + @JsonProperty("_meta") Map meta, @JsonProperty("mode") String mode, + @JsonProperty("url") String url, @JsonProperty("elicitationId") String elicitationId) { + if (message == null || (!"url".equals(mode) && requestedSchema == null)) { List missing = new ArrayList<>(); if (message == null) { missing.add("message -> ''"); message = ""; } - if (requestedSchema == null) { + if (!"url".equals(mode) && requestedSchema == null) { missing.add("requestedSchema -> {}"); requestedSchema = Map.of(); } logger.warn("ElicitRequest: missing required fields during deserialization: {}", String.join(", ", missing)); } - return new ElicitRequest(message, requestedSchema, meta); + return new ElicitRequest(message, requestedSchema, meta, mode, url, elicitationId); } - // backwards compatibility constructor + // backwards compatibility constructor (form mode, no meta) public ElicitRequest(String message, Map requestedSchema) { - this(message, requestedSchema, null); + this(message, requestedSchema, null, null, null, null); + } + + // backwards compatibility constructor (form mode, with meta) + public ElicitRequest(String message, Map requestedSchema, Map meta) { + this(message, requestedSchema, meta, null, null, null); } /** @@ -3748,6 +3785,17 @@ public static Builder builder(String message, Map requestedSchem return new Builder(message, requestedSchema); } + /** + * Creates a builder for URL mode elicitation requests. + * @param message the message to present to the user + * @param url the URL to direct the user to + * @param elicitationId a unique identifier for this elicitation + * @return a new builder configured for URL mode + */ + public static Builder urlBuilder(String message, String url, String elicitationId) { + return new Builder(message, url, elicitationId); + } + public static class Builder { private String message; @@ -3756,6 +3804,12 @@ public static class Builder { private Map meta; + private String mode; + + private String url; + + private String elicitationId; + /** * @deprecated Use {@link ElicitRequest#builder(String, Map)} factory method * instead. @@ -3771,6 +3825,16 @@ private Builder(String message, Map requestedSchema) { this.requestedSchema = requestedSchema; } + 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.mode = "url"; + this.message = message; + this.url = url; + this.elicitationId = elicitationId; + } + public Builder message(String message) { Assert.notNull(message, "message must not be null"); this.message = message; @@ -3783,6 +3847,21 @@ public Builder requestedSchema(Map requestedSchema) { return this; } + public Builder mode(String mode) { + this.mode = mode; + return this; + } + + public Builder url(String url) { + this.url = url; + return this; + } + + public Builder elicitationId(String elicitationId) { + this.elicitationId = elicitationId; + return this; + } + public Builder meta(Map meta) { this.meta = meta; return this; @@ -3798,8 +3877,17 @@ public Builder progressToken(Object progressToken) { public ElicitRequest build() { Assert.notNull(message, "message must not be null"); - Assert.notNull(requestedSchema, "requestedSchema must not be null"); - return new ElicitRequest(message, requestedSchema, meta); + if ("url".equals(this.mode)) { + Assert.notNull(url, "url must not be null when mode is 'url'"); + Assert.notNull(elicitationId, "elicitationId must not be null when mode is 'url'"); + if (requestedSchema != null) { + throw new IllegalArgumentException("requestedSchema must not be set when mode is 'url'"); + } + } + else { + Assert.notNull(requestedSchema, "requestedSchema must not be null for form mode"); + } + return new ElicitRequest(message, requestedSchema, meta, mode, url, elicitationId); } } @@ -3903,6 +3991,28 @@ public ElicitResult build() { } } + /** + * 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 meta) implements Notification { // @formatter:on + + public ElicitationCompleteNotification { + Assert.notNull(elicitationId, "elicitationId must not be null"); + } + + public ElicitationCompleteNotification(String elicitationId) { + this(elicitationId, null); + } + } + // --------------------------- // Pagination Interfaces // --------------------------- diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java index e58e59e68..520035b0b 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/server/McpAsyncServerExchangeTests.java @@ -4,31 +4,31 @@ 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.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; -import org.mockito.MockitoAnnotations; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import org.mockito.Mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import org.mockito.MockitoAnnotations; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerSession; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; /** * Tests for {@link McpAsyncServerExchange}. @@ -461,6 +461,85 @@ void testCreateElicitationWithSessionError() { }); } + // --------------------------------------- + // URL Elicitation Tests (SEP-1036) + // --------------------------------------- + + @Test + void testCreateUrlElicitationWithoutUrlCapability() { + // Client has elicitation but not URL sub-capability + McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder().elicitation().build(); + + McpAsyncServerExchange exchange = new McpAsyncServerExchange("testSessionId", mockSession, capabilities, + clientInfo, McpTransportContext.EMPTY); + + McpSchema.ElicitRequest urlRequest = McpSchema.ElicitRequest + .urlBuilder("Authenticate", "https://example.com/oauth", "elicit-1") + .build(); + + StepVerifier.create(exchange.createElicitation(urlRequest)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("URL elicitation capabilities"); + }); + } + + @Test + void testCreateUrlElicitationWithUrlCapability() { + McpSchema.ClientCapabilities capabilities = new McpSchema.ClientCapabilities(null, null, null, + new McpSchema.ClientCapabilities.Elicitation(null, new McpSchema.ClientCapabilities.Elicitation.Url())); + + McpAsyncServerExchange exchange = new McpAsyncServerExchange("testSessionId", mockSession, capabilities, + clientInfo, McpTransportContext.EMPTY); + + McpSchema.ElicitRequest urlRequest = McpSchema.ElicitRequest + .urlBuilder("Authenticate", "https://example.com/oauth", "elicit-1") + .build(); + + McpSchema.ElicitResult expectedResult = McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT) + .build(); + + when(mockSession.sendRequest(eq(McpSchema.METHOD_ELICITATION_CREATE), eq(urlRequest), any(TypeRef.class))) + .thenReturn(Mono.just(expectedResult)); + + StepVerifier.create(exchange.createElicitation(urlRequest)).assertNext(result -> { + assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT); + }).verifyComplete(); + } + + @Test + void testSendElicitationCompleteWithUrlCapability() { + McpSchema.ClientCapabilities capabilities = new McpSchema.ClientCapabilities(null, null, null, + new McpSchema.ClientCapabilities.Elicitation(null, new McpSchema.ClientCapabilities.Elicitation.Url())); + + McpAsyncServerExchange exchange = new McpAsyncServerExchange("testSessionId", mockSession, capabilities, + clientInfo, McpTransportContext.EMPTY); + + McpSchema.ElicitationCompleteNotification notification = new McpSchema.ElicitationCompleteNotification( + "elicit-1"); + + when(mockSession.sendNotification(eq(McpSchema.METHOD_NOTIFICATION_ELICITATION_COMPLETE), eq(notification))) + .thenReturn(Mono.empty()); + + StepVerifier.create(exchange.sendElicitationComplete(notification)).verifyComplete(); + } + + @Test + void testSendElicitationCompleteWithoutUrlCapability() { + // Client has elicitation but not URL sub-capability + McpSchema.ClientCapabilities capabilities = McpSchema.ClientCapabilities.builder().elicitation().build(); + + McpAsyncServerExchange exchange = new McpAsyncServerExchange("testSessionId", mockSession, capabilities, + clientInfo, McpTransportContext.EMPTY); + + McpSchema.ElicitationCompleteNotification notification = new McpSchema.ElicitationCompleteNotification( + "elicit-1"); + + StepVerifier.create(exchange.sendElicitationComplete(notification)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(IllegalStateException.class) + .hasMessageContaining("URL elicitation capabilities"); + }); + } + // --------------------------------------- // Create Message Tests // --------------------------------------- diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java index c83d0960b..ced9395e6 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/spec/McpSchemaTests.java @@ -4,12 +4,6 @@ package io.modelcontextprotocol.spec; -import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; -import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - import java.io.IOException; import java.util.Arrays; import java.util.Collections; @@ -17,11 +11,16 @@ import java.util.List; import java.util.Map; -import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import org.assertj.core.api.InstanceOfAssertFactories; import org.junit.jupiter.api.Test; import io.modelcontextprotocol.json.TypeRef; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; +import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; +import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json; import net.javacrumbs.jsonunit.core.Option; /** @@ -1567,6 +1566,135 @@ void testElicitRequestWithMeta() throws Exception { assertThat(request.progressToken()).isEqualTo("elicit-token-789"); } + // URL Elicitation Tests (SEP-1036) + + @Test + void testElicitRequestUrlMode() throws Exception { + McpSchema.ElicitRequest request = McpSchema.ElicitRequest + .urlBuilder("Please authenticate", "https://example.com/oauth", "elicit-123") + .build(); + + String value = JSON_MAPPER.writeValueAsString(request); + assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER) + .isObject() + .containsEntry("mode", "url") + .containsEntry("message", "Please authenticate") + .containsEntry("url", "https://example.com/oauth") + .containsEntry("elicitationId", "elicit-123"); + } + + @Test + void testElicitRequestDeserializesWithoutMode() throws Exception { + McpSchema.ElicitRequest request = JSON_MAPPER.readValue(""" + {"message":"hello","requestedSchema":{"type":"object"}}""", McpSchema.ElicitRequest.class); + assertThat(request.mode()).isNull(); + } + + @Test + void testElicitRequestOmitsNullMode() throws Exception { + McpSchema.ElicitRequest request = new McpSchema.ElicitRequest("msg", Map.of("type", "object")); + String json = JSON_MAPPER.writeValueAsString(request); + assertThat(json).doesNotContain("mode"); + } + + @Test + void testElicitRequestDeserializesWithoutUrl() throws Exception { + McpSchema.ElicitRequest request = JSON_MAPPER.readValue(""" + {"message":"hello","requestedSchema":{"type":"object"}}""", McpSchema.ElicitRequest.class); + assertThat(request.url()).isNull(); + } + + @Test + void testElicitRequestOmitsNullUrl() throws Exception { + McpSchema.ElicitRequest request = new McpSchema.ElicitRequest("msg", Map.of("type", "object")); + String json = JSON_MAPPER.writeValueAsString(request); + assertThat(json).doesNotContain("url"); + } + + @Test + void testElicitRequestDeserializesWithoutElicitationId() throws Exception { + McpSchema.ElicitRequest request = JSON_MAPPER.readValue(""" + {"message":"hello","requestedSchema":{"type":"object"}}""", McpSchema.ElicitRequest.class); + assertThat(request.elicitationId()).isNull(); + } + + @Test + void testElicitRequestOmitsNullElicitationId() throws Exception { + McpSchema.ElicitRequest request = new McpSchema.ElicitRequest("msg", Map.of("type", "object")); + String json = JSON_MAPPER.writeValueAsString(request); + assertThat(json).doesNotContain("elicitationId"); + } + + @Test + void testElicitRequestToleratesUnknownFields() throws Exception { + McpSchema.ElicitRequest request = JSON_MAPPER.readValue(""" + {"message":"hello","requestedSchema":{"type":"object"},"futureField":42}""", + McpSchema.ElicitRequest.class); + assertThat(request.message()).isEqualTo("hello"); + } + + @Test + void testElicitRequestUrlModeRoundTrip() throws Exception { + McpSchema.ElicitRequest original = McpSchema.ElicitRequest + .urlBuilder("Authenticate via OAuth", "https://auth.example.com/callback", "elicit-456") + .meta(Map.of("progressToken", "tok-1")) + .build(); + + String json = JSON_MAPPER.writeValueAsString(original); + McpSchema.ElicitRequest deserialized = JSON_MAPPER.readValue(json, McpSchema.ElicitRequest.class); + + assertThat(deserialized.mode()).isEqualTo("url"); + assertThat(deserialized.message()).isEqualTo("Authenticate via OAuth"); + assertThat(deserialized.url()).isEqualTo("https://auth.example.com/callback"); + assertThat(deserialized.elicitationId()).isEqualTo("elicit-456"); + assertThat(deserialized.requestedSchema()).isNull(); + assertThat(deserialized.meta()).containsEntry("progressToken", "tok-1"); + } + + @Test + void testElicitRequestFormModeBackwardCompatibility() throws Exception { + // Old-style form request without mode field should still work + McpSchema.ElicitRequest request = new McpSchema.ElicitRequest("Enter name", Map.of("type", "object")); + + String json = JSON_MAPPER.writeValueAsString(request); + assertThat(json).doesNotContain("mode"); + assertThat(json).contains("message"); + assertThat(json).contains("requestedSchema"); + + McpSchema.ElicitRequest deserialized = JSON_MAPPER.readValue(json, McpSchema.ElicitRequest.class); + assertThat(deserialized.mode()).isNull(); + assertThat(deserialized.message()).isEqualTo("Enter name"); + assertThat(deserialized.requestedSchema()).containsEntry("type", "object"); + } + + @Test + void testElicitationCompleteNotification() throws Exception { + McpSchema.ElicitationCompleteNotification notification = new McpSchema.ElicitationCompleteNotification( + "elicit-789"); + + String json = JSON_MAPPER.writeValueAsString(notification); + assertThatJson(json).isObject().containsEntry("elicitationId", "elicit-789"); + + McpSchema.ElicitationCompleteNotification deserialized = JSON_MAPPER.readValue(json, + McpSchema.ElicitationCompleteNotification.class); + assertThat(deserialized.elicitationId()).isEqualTo("elicit-789"); + } + + @Test + void testElicitationCompleteNotificationToleratesUnknownFields() throws Exception { + McpSchema.ElicitationCompleteNotification notification = JSON_MAPPER.readValue(""" + {"elicitationId":"abc","futureField":"ignored"}""", McpSchema.ElicitationCompleteNotification.class); + assertThat(notification.elicitationId()).isEqualTo("abc"); + } + + @Test + void testElicitRequestUrlModeBuilderRejectsRequestedSchema() { + assertThatThrownBy(() -> McpSchema.ElicitRequest.urlBuilder("msg", "https://example.com", "id-1") + .requestedSchema(Map.of("type", "object")) + .build()).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requestedSchema must not be set when mode is 'url'"); + } + // Pagination Tests @Test