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

@ -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
// ---------------------------------------