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
3 changed files with 1413 additions and 196 deletions

View file

@ -402,7 +402,7 @@ class McpAsyncClientResponseHandlerTests {
MockMcpClientTransport transport = initializationEnabledTransport();
// Create a test elicitation handler that echoes back the input
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler = request -> {
Function<McpSchema.ElicitFormRequest, Mono<McpSchema.ElicitResult>> elicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
assertThat(request.requestedSchema()).isInstanceOf(Map.class);
assertThat(request.requestedSchema().get("type")).isEqualTo("object");
@ -458,7 +458,7 @@ class McpAsyncClientResponseHandlerTests {
MockMcpClientTransport transport = initializationEnabledTransport();
// Create a test elicitation handler to decline the request
Function<McpSchema.ElicitRequest, Mono<McpSchema.ElicitResult>> elicitationHandler = request -> Mono
Function<McpSchema.ElicitFormRequest, Mono<McpSchema.ElicitResult>> elicitationHandler = request -> Mono
.just(McpSchema.ElicitResult.builder(action).build());
// Create client with elicitation capability and handler
@ -534,17 +534,6 @@ class McpAsyncClientResponseHandlerTests {
asyncMcpClient.closeGracefully();
}
@Test
void testElicitationCreateRequestHandlingWithNullHandler() {
MockMcpClientTransport transport = new MockMcpClientTransport();
// Create client with elicitation capability but null handler
assertThatThrownBy(() -> McpClient.async(transport)
.capabilities(ClientCapabilities.builder().elicitation().build())
.build()).isInstanceOf(IllegalArgumentException.class)
.hasMessage("Elicitation handler must not be null when client capabilities include elicitation");
}
@Test
void testPingMessageRequestHandling() {
MockMcpClientTransport transport = initializationEnabledTransport();

View file

@ -0,0 +1,30 @@
/*
* Copyright 2026 - 2026 the original author or authors.
*/
package io.modelcontextprotocol.spec;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class McpErrorTests {
@Test
void testUrlElicitationRequired() {
McpSchema.ElicitUrlRequest elicitation = McpSchema.ElicitUrlRequest
.builder("Please auth", "https://example.com", "123")
.build();
McpError error = McpError.URL_ELICITATION_REQUIRED.apply(List.of(elicitation));
assertThat(error.getJsonRpcError().code()).isEqualTo(McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED);
assertThat(error.getJsonRpcError().message()).isEqualTo("URL elicitation required");
assertThat(error.getJsonRpcError().data()).isInstanceOf(Map.class);
Map<String, Object> data = (Map<String, Object>) error.getJsonRpcError().data();
assertThat(data).containsEntry("elicitations", List.of(elicitation));
}
}

View file

@ -1,15 +1,9 @@
/*
* Copyright 2025 - 2026 the original author or authors.
*/
* Copyright 2025 - 2026 the original author or authors.
*/
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,12 +11,17 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.spec.McpSchema.TextResourceContents;
import net.javacrumbs.jsonunit.core.Option;
import org.assertj.core.api.InstanceOfAssertFactories;
import org.junit.jupiter.api.Test;
import io.modelcontextprotocol.json.TypeRef;
import net.javacrumbs.jsonunit.core.Option;
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;
/**
* @author Christian Tzolov
@ -1557,7 +1556,7 @@ public class McpSchemaTests {
@Test
void testCreateElicitationRequest() throws Exception {
McpSchema.ElicitRequest request = McpSchema.ElicitRequest
McpSchema.ElicitRequest request = McpSchema.ElicitFormRequest
.builder("Please provide additional information", Map.of("type", "object", "required", List.of("a"),
"properties", Map.of("foo", Map.of("type", "string"))))
.build();
@ -1567,9 +1566,43 @@ public class McpSchemaTests {
assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isObject()
.isEqualTo(
json("""
{"message":"Please provide additional information","requestedSchema":{"properties":{"foo":{"type":"string"}},"required":["a"],"type":"object"}}"""));
.isEqualTo(json("""
{
"mode": "form",
"message": "Please provide additional information",
"requestedSchema": {
"properties": {
"foo": {
"type": "string"
}
},
"required": [
"a"
],
"type": "object"
}
}"""));
}
@Test
void testCreateElicitationUrlRequest() throws Exception {
McpSchema.ElicitRequest request = McpSchema.ElicitUrlRequest
.builder("Please visit the URL", "https://example.com/oauth", "elicit-oauth-123")
.build();
String value = JSON_MAPPER.writeValueAsString(request);
assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isObject()
.isEqualTo(json("""
{
"mode": "url",
"message": "Please visit the URL",
"url": "https://example.com/oauth",
"elicitationId": "elicit-oauth-123"
}
"""));
}
@Test
@ -1588,12 +1621,57 @@ public class McpSchemaTests {
}
@Test
void testElicitRequestDeserializationWithMissingRequiredFields() throws Exception {
McpSchema.ElicitRequest request = JSON_MAPPER.readValue("{}", McpSchema.ElicitRequest.class);
void testElicitRequestDeserializationDefaultsToForm() throws Exception {
var request = JSON_MAPPER.readValue("{\"message\":\"do the thing\"}", McpSchema.ElicitRequest.class);
assertThat(request).isNotNull();
assertThat(request).isNotNull().isInstanceOf(McpSchema.ElicitFormRequest.class);
assertThat(request.message()).isEqualTo("do the thing");
assertThat(request.mode()).isEqualTo("form");
var formRequest = (McpSchema.ElicitFormRequest) request;
assertThat(formRequest.requestedSchema()).isEmpty();
}
@Test
void testElicitRequestDeserializationWithMissingRequiredFields() throws Exception {
var request = JSON_MAPPER.readValue("{\"mode\":\"form\"}", McpSchema.ElicitRequest.class);
assertThat(request).isNotNull().isInstanceOf(McpSchema.ElicitFormRequest.class);
assertThat(request.message()).isEmpty();
assertThat(request.requestedSchema()).isEmpty();
assertThat(request.mode()).isEqualTo("form");
var formRequest = (McpSchema.ElicitFormRequest) request;
assertThat(formRequest.requestedSchema()).isEmpty();
}
@Test
void testElicitUrlRequestDeserializationWithMissingRequiredFields() throws Exception {
McpSchema.ElicitRequest request = JSON_MAPPER.readValue("{\"mode\":\"url\"}", McpSchema.ElicitRequest.class);
assertThat(request).isNotNull().isInstanceOf(McpSchema.ElicitUrlRequest.class);
assertThat(request.message()).isEmpty();
assertThat(request.mode()).isEqualTo("url");
var urlRequest = (McpSchema.ElicitUrlRequest) request;
assertThat(urlRequest.url()).isEmpty();
assertThat(urlRequest.elicitationId()).isEmpty();
}
@Test
void testElicitUrlDeserialization() throws Exception {
McpSchema.ElicitRequest request = JSON_MAPPER.readValue("""
{
"mode": "url",
"message": "Please visit the URL",
"url": "https://example.com/oauth",
"elicitationId": "elicit-oauth-123"
}
""", McpSchema.ElicitRequest.class);
assertThat(request).isNotNull().isInstanceOf(McpSchema.ElicitUrlRequest.class);
assertThat(request.message()).isEqualTo("Please visit the URL");
assertThat(request.mode()).isEqualTo("url");
var urlRequest = (McpSchema.ElicitUrlRequest) request;
assertThat(urlRequest.url()).isEqualTo("https://example.com/oauth");
assertThat(urlRequest.elicitationId()).isEqualTo("elicit-oauth-123");
}
@Test
@ -1604,7 +1682,8 @@ public class McpSchemaTests {
Map<String, Object> meta = new HashMap<>();
meta.put("progressToken", "elicit-token-789");
McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder("Please provide your name", requestedSchema)
McpSchema.ElicitRequest request = McpSchema.ElicitFormRequest
.builder("Please provide your name", requestedSchema)
.meta(meta)
.build();
@ -1612,7 +1691,8 @@ public class McpSchemaTests {
assertThatJson(value).when(Option.IGNORING_ARRAY_ORDER)
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
.isObject()
.containsEntry("_meta", Map.of("progressToken", "elicit-token-789"));
.containsEntry("_meta", Map.of("progressToken", "elicit-token-789"))
.containsEntry("mode", "form");
// Test Request interface methods
assertThat(request.meta()).isEqualTo(meta);
@ -1627,16 +1707,25 @@ public class McpSchemaTests {
requestedSchema.put("properties", Map.of("name", Map.of("type", "string")));
requestedSchema.put("required", List.of("name"));
McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder("Please provide name", requestedSchema)
McpSchema.ElicitRequest request = McpSchema.ElicitFormRequest.builder("Please provide name", requestedSchema)
.build();
String json = JSON_MAPPER.writeValueAsString(request);
assertThatJson(json).inPath("$.requestedSchema.$schema").isEqualTo(McpSchema.JSON_SCHEMA_DIALECT_2020_12);
McpSchema.ElicitRequest parsed = JSON_MAPPER.readValue(json, McpSchema.ElicitRequest.class);
McpSchema.ElicitFormRequest parsed = (McpSchema.ElicitFormRequest) JSON_MAPPER.readValue(json,
McpSchema.ElicitRequest.class);
assertThat(parsed.requestedSchema()).containsEntry("$schema", McpSchema.JSON_SCHEMA_DIALECT_2020_12);
}
@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");
}
// Pagination Tests
@Test
@ -1889,15 +1978,13 @@ public class McpSchemaTests {
@Test
void testElicitRequestWithDefaultValues() throws Exception {
// Test that schemas with default values serialize correctly in an ElicitRequest
McpSchema.ElicitRequest request = McpSchema.ElicitRequest.builder()
.message("Please provide your info")
.requestedSchema(Map.of("type", "object", "properties",
Map.of("name", Map.of("type", "string", "default", "John Doe"), "age",
Map.of("type", "integer", "default", 30), "score",
Map.of("type", "number", "default", 95.5), "status",
Map.of("type", "string", "enum", List.of("active", "inactive"), "default", "active"),
"verified", Map.of("type", "boolean", "default", true)),
"required", List.of("name")))
McpSchema.ElicitRequest request = McpSchema.ElicitFormRequest.builder("Please provide your info", Map.of("type",
"object", "properties",
Map.of("name", Map.of("type", "string", "default", "John Doe"), "age",
Map.of("type", "integer", "default", 30), "score", Map.of("type", "number", "default", 95.5),
"status", Map.of("type", "string", "enum", List.of("active", "inactive"), "default", "active"),
"verified", Map.of("type", "boolean", "default", true)),
"required", List.of("name")))
.build();
String value = JSON_MAPPER.writeValueAsString(request);
@ -1909,6 +1996,41 @@ public class McpSchemaTests {
assertThatJson(value).node("requestedSchema.properties.verified.default").isEqualTo(true);
}
// Elicitation Complete Notification Tests (SEP-1036)
@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 testElicitationCompleteNotificationNullElicitationIdThrows() {
assertThatThrownBy(() -> new McpSchema.ElicitationCompleteNotification(null))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void testElicitationCompleteNotificationDeserializesWithoutElicitationId() throws Exception {
McpSchema.ElicitationCompleteNotification notification = JSON_MAPPER.readValue("""
{}""", McpSchema.ElicitationCompleteNotification.class);
assertThat(notification.elicitationId()).isEqualTo("");
}
@Test
void testElicitationCompleteNotificationToleratesUnknownFields() throws Exception {
McpSchema.ElicitationCompleteNotification notification = JSON_MAPPER.readValue("""
{"elicitationId":"abc","futureField":"ignored"}""", McpSchema.ElicitationCompleteNotification.class);
assertThat(notification.elicitationId()).isEqualTo("abc");
}
// Progress Notification Tests
@Test