Skip to content

Commit dbb9bda

Browse files
KehrlannSainath Reddy Bobbala
andauthored
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>
1 parent c49a994 commit dbb9bda

18 files changed

Lines changed: 1413 additions & 196 deletions

File tree

docs/client.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,20 +270,28 @@ This capability allows:
270270
Elicitation enables servers to request additional information or user input through the client. This is useful when a server needs clarification or confirmation during an operation:
271271

272272
```java
273-
// Configure elicitation handler
274-
Function<ElicitRequest, ElicitResult> elicitationHandler = request -> {
273+
// Configure form elicitation handler
274+
Function<ElicitFormRequest, ElicitResult> formElicitationHandler = request -> {
275275
// Present the request to the user and collect their response
276276
// The request contains a message and a schema describing the expected input
277277
Map<String, Object> userResponse = collectUserInput(request.message(), request.requestedSchema());
278278
return new ElicitResult(ElicitResult.Action.ACCEPT, userResponse);
279279
};
280280

281+
// Configure URL elicitation handler
282+
Function<ElicitUrlRequest, ElicitResult> urlElicitationHandler = request -> {
283+
// Prompt the user to visit the URL
284+
// e.g. openBrowser(request.url());
285+
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of());
286+
};
287+
281288
// Create client with elicitation support
282289
var client = McpClient.sync(transport)
283290
.capabilities(ClientCapabilities.builder()
284-
.elicitation()
291+
.elicitation(true, true) // enables both form and URL elicitation
285292
.build())
286-
.elicitation(elicitationHandler)
293+
.elicitation(formElicitationHandler)
294+
.urlElicitation(urlElicitationHandler)
287295
.build();
288296
```
289297

@@ -293,6 +301,28 @@ The `ElicitResult` supports three actions:
293301
- `DECLINE` - The user declined to provide the information
294302
- `CANCEL` - The operation was cancelled
295303

304+
#### URL Elicitation Required Handling
305+
306+
When a server requires out-of-band URL elicitation but the client has not negotiated support for it (or the server strictly requires out-of-band handling), the server may return a `URL_ELICITATION_REQUIRED` error during tool execution or prompt retrieval.
307+
308+
```java
309+
try {
310+
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
311+
} catch (McpError e) {
312+
if (e.getJsonRpcError().code() == McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED) {
313+
// Extract elicitation requests from the error data
314+
Map<String, Object> data = (Map<String, Object>) e.getJsonRpcError().data();
315+
TypeRef<List<McpSchema.ElicitUrlRequest>> typeRef = new TypeRef<>() {};
316+
var requests = McpJsonDefaults.getMapper()
317+
.convertValue(data.get("elicitations"), typeRef);
318+
319+
for (var req : requests) {
320+
// handle elicitation requests
321+
}
322+
}
323+
}
324+
```
325+
296326
### Logging Support
297327

298328
The client can register a logging consumer to receive log messages from the server and set the minimum logging level to filter messages:

docs/server.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -715,9 +715,7 @@ var tool = SyncToolSpecification.builder()
715715
}
716716

717717
// Request user confirmation
718-
ElicitRequest elicitRequest = ElicitRequest.builder()
719-
.message("Do you want to proceed with this action?")
720-
.requestedSchema(Map.of(
718+
ElicitRequest elicitRequest = ElicitFormRequest.builder("Do you want to proceed with this action?", Map.of(
721719
"type", "object",
722720
"properties", Map.of("confirmed", Map.of("type", "boolean"))
723721
))
@@ -739,6 +737,40 @@ var tool = SyncToolSpecification.builder()
739737
.build();
740738
```
741739

740+
To request out-of-band URL elicitation, such as a user authorizing an OAuth flow:
741+
742+
```java
743+
var urlTool = SyncToolSpecification.builder()
744+
.tool(Tool.builder()
745+
.name("oauth-auth")
746+
.description("Authenticates via OAuth")
747+
.inputSchema(schema)
748+
.build())
749+
.callHandler((exchange, request) -> {
750+
// Request URL elicitation from client
751+
if (
752+
exchange.getClientCapabilities().elicitation() != null
753+
&& exchange.getClientCapabilities().elicitation().url() != null
754+
) {
755+
ElicitRequest urlRequest = McpSchema.ElicitUrlRequest
756+
.builder("Please authenticate", "https://example.com/oauth", "oauth-123").build();
757+
ElicitResult result = exchange.elicit(urlRequest);
758+
// handle result.action == CANCELLED or DENIED
759+
if (result.action() != ElicitResult.Action.ACCEPT) {
760+
return CallToolResult.builder()
761+
.content(List.of(new McpSchema.TextContent("Authentication failed or cancelled")))
762+
.build();
763+
}
764+
}
765+
766+
// wait for user to visit the URL
767+
return CallToolResult.builder()
768+
.content(List.of(new McpSchema.TextContent("Authentication successful")))
769+
.build();
770+
})
771+
.build();
772+
```
773+
742774
### Logging Support
743775

744776
The server provides structured logging capabilities that allow sending log messages to clients with different severity levels.

mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java

Lines changed: 74 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,6 @@
1515
import java.util.concurrent.ConcurrentHashMap;
1616
import java.util.function.Function;
1717

18-
import org.slf4j.Logger;
19-
import org.slf4j.LoggerFactory;
20-
2118
import io.modelcontextprotocol.client.LifecycleInitializer.Initialization;
2219
import io.modelcontextprotocol.json.TypeRef;
2320
import io.modelcontextprotocol.json.schema.JsonSchemaValidator;
@@ -29,8 +26,10 @@
2926
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
3027
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
3128
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
29+
import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest;
3230
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
3331
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
32+
import io.modelcontextprotocol.spec.McpSchema.ElicitUrlRequest;
3433
import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest;
3534
import io.modelcontextprotocol.spec.McpSchema.GetPromptResult;
3635
import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult;
@@ -41,6 +40,8 @@
4140
import io.modelcontextprotocol.util.Assert;
4241
import io.modelcontextprotocol.util.ToolNameValidator;
4342
import io.modelcontextprotocol.util.Utils;
43+
import org.slf4j.Logger;
44+
import org.slf4j.LoggerFactory;
4445
import reactor.core.publisher.Flux;
4546
import reactor.core.publisher.Mono;
4647

@@ -108,6 +109,9 @@ public class McpAsyncClient {
108109
public static final TypeRef<McpSchema.ProgressNotification> PROGRESS_NOTIFICATION_TYPE_REF = new TypeRef<>() {
109110
};
110111

112+
public static final TypeRef<McpSchema.ElicitationCompleteNotification> ELICITATION_COMPLETE_NOTIFICATION_TYPE_REF = new TypeRef<>() {
113+
};
114+
111115
public static final String NEGOTIATED_PROTOCOL_VERSION = "io.modelcontextprotocol.client.negotiated-protocol-version";
112116

113117
/**
@@ -145,7 +149,14 @@ public class McpAsyncClient {
145149
* necessary information dynamically. Servers can request structured data from users
146150
* with optional JSON schemas to validate responses.
147151
*/
148-
private Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler;
152+
private Function<ElicitFormRequest, Mono<ElicitResult>> formElicitationHandler;
153+
154+
/**
155+
* MCP provides a standardized way for servers to request additional information from
156+
* users out-of-band during interactions. This flow allows users to share information
157+
* with the server without sharing it with the client.
158+
*/
159+
private Function<ElicitUrlRequest, Mono<ElicitResult>> urlElicitationHandler;
149160

150161
/**
151162
* Client transport implementation.
@@ -226,11 +237,21 @@ public class McpAsyncClient {
226237

227238
// Elicitation Handler
228239
if (this.clientCapabilities.elicitation() != null) {
229-
if (features.elicitationHandler() == null) {
240+
// elicitation: {} is equivalent to elicitation: { form: {} } for
241+
// backwards-compatiblity
242+
var supportsForm = this.clientCapabilities.elicitation().form() != null
243+
|| this.clientCapabilities.elicitation().url() == null;
244+
var supportsUrl = this.clientCapabilities.elicitation().url() != null;
245+
if (supportsForm && features.formElicitationHandler() == null) {
230246
throw new IllegalArgumentException(
231-
"Elicitation handler must not be null when client capabilities include elicitation");
247+
"Form elicitation handler must not be null when client capabilities include form elicitation");
232248
}
233-
this.elicitationHandler = features.elicitationHandler();
249+
if (supportsUrl && features.urlElicitationHandler() == null) {
250+
throw new IllegalArgumentException(
251+
"URL elicitation handler must not be null when client capabilities include URL elicitation");
252+
}
253+
this.formElicitationHandler = features.formElicitationHandler();
254+
this.urlElicitationHandler = features.urlElicitationHandler();
234255
requestHandlers.put(McpSchema.METHOD_ELICITATION_CREATE, elicitationCreateHandler());
235256
}
236257

@@ -301,6 +322,16 @@ public class McpAsyncClient {
301322
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_PROGRESS,
302323
asyncProgressNotificationHandler(progressConsumersFinal));
303324

325+
// Elicitation Complete Notification
326+
List<Function<McpSchema.ElicitationCompleteNotification, Mono<Void>>> elicitationCompleteConsumersFinal = new ArrayList<>();
327+
elicitationCompleteConsumersFinal
328+
.add((notification) -> Mono.fromRunnable(() -> logger.debug("Elicitation complete: {}", notification)));
329+
if (!Utils.isEmpty(features.elicitationCompleteConsumers())) {
330+
elicitationCompleteConsumersFinal.addAll(features.elicitationCompleteConsumers());
331+
}
332+
notificationHandlers.put(McpSchema.METHOD_NOTIFICATION_ELICITATION_COMPLETE,
333+
asyncElicitationCompleteNotificationHandler(elicitationCompleteConsumersFinal));
334+
304335
Function<Initialization, Mono<Void>> postInitializationHook = init -> {
305336

306337
if (init.initializeResult().capabilities().tools() == null || !enableCallToolSchemaCaching) {
@@ -552,23 +583,47 @@ private RequestHandler<CreateMessageResult> samplingCreateMessageHandler() {
552583
};
553584
}
554585

555-
// --------------------------
556-
// Elicitation
557-
// --------------------------
558586
private RequestHandler<ElicitResult> elicitationCreateHandler() {
559587
return params -> {
560-
ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() {
588+
McpSchema.ElicitRequest request = transport.unmarshalFrom(params, new TypeRef<>() {
561589
});
562590

563-
return this.elicitationHandler.apply(request).map(result -> {
564-
if (this.applyElicitationDefaults && result.action() == ElicitResult.Action.ACCEPT
565-
&& result.content() != null) {
566-
Map<String, Object> merged = new HashMap<>(result.content());
567-
applyElicitationDefaults(request.requestedSchema(), merged);
568-
return new ElicitResult(result.action(), merged, result.meta());
591+
if (request instanceof ElicitUrlRequest urlRequest) {
592+
if (this.urlElicitationHandler == null) {
593+
return Mono.error(new IllegalStateException(
594+
"Received URL elicitation request, but urlElicitation handler is null"));
569595
}
570-
return result;
571-
});
596+
return this.urlElicitationHandler.apply(urlRequest);
597+
}
598+
else if (request instanceof ElicitFormRequest formRequest) {
599+
if (this.formElicitationHandler == null) {
600+
return Mono.error(new IllegalStateException(
601+
"Received FORM elicitation request, but formElicitationHandler handler is null"));
602+
}
603+
return this.formElicitationHandler.apply(formRequest).map(result -> {
604+
if (this.applyElicitationDefaults && result.action() == ElicitResult.Action.ACCEPT
605+
&& result.content() != null) {
606+
Map<String, Object> merged = new HashMap<>(result.content());
607+
applyElicitationDefaults(formRequest.requestedSchema(), merged);
608+
return new ElicitResult(result.action(), merged, result.meta());
609+
}
610+
return result;
611+
});
612+
}
613+
614+
return Mono.error(new IllegalStateException("Unknown elictation type deserialized"));
615+
};
616+
}
617+
618+
private NotificationHandler asyncElicitationCompleteNotificationHandler(
619+
List<Function<McpSchema.ElicitationCompleteNotification, Mono<Void>>> elicitationCompleteConsumers) {
620+
return params -> {
621+
McpSchema.ElicitationCompleteNotification notification = transport.unmarshalFrom(params,
622+
ELICITATION_COMPLETE_NOTIFICATION_TYPE_REF);
623+
624+
return Flux.fromIterable(elicitationCompleteConsumers)
625+
.flatMap(consumer -> consumer.apply(notification))
626+
.then();
572627
};
573628
}
574629

0 commit comments

Comments
 (0)