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

View file

@ -4,8 +4,6 @@
package io.modelcontextprotocol;
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
@ -21,6 +19,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiFunction;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
@ -39,7 +38,7 @@ import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageRequest;
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest;
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
import io.modelcontextprotocol.spec.McpSchema.ModelPreferences;
@ -51,7 +50,6 @@ import io.modelcontextprotocol.spec.McpSchema.Root;
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.util.McpJsonMapperUtils;
import io.modelcontextprotocol.util.Utils;
import net.javacrumbs.jsonunit.core.Option;
import org.junit.jupiter.params.ParameterizedTest;
@ -59,11 +57,16 @@ import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.ValueSource;
import reactor.core.publisher.Mono;
import static io.modelcontextprotocol.util.ToolsUtils.EMPTY_JSON_SCHEMA;
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.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertWith;
import static org.assertj.core.api.InstanceOfAssertFactories.LIST;
import static org.assertj.core.api.InstanceOfAssertFactories.MAP;
import static org.assertj.core.api.InstanceOfAssertFactories.type;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.mock;
@ -381,7 +384,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> exchange.createElicitation(mock(ElicitRequest.class))
.callHandler((exchange, request) -> exchange.createElicitation(mock(McpSchema.ElicitFormRequest.class))
.then(Mono.just(mock(CallToolResult.class))))
.build();
@ -412,7 +415,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
var clientBuilder = clientBuilders.get(clientType);
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> {
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> formElicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
assertThat(request.requestedSchema()).isNotNull();
@ -431,7 +434,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> {
var elicitationRequest = McpSchema.ElicitRequest
var elicitationRequest = McpSchema.ElicitFormRequest
.builder("Test message",
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
.build();
@ -447,7 +450,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
try (var mcpClient = clientBuilder
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.capabilities(ClientCapabilities.builder().elicitation().build())
.elicitation(elicitationHandler)
.elicitation(formElicitationHandler)
.build()) {
InitializeResult initResult = mcpClient.initialize();
@ -476,7 +479,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
var clientBuilder = clientBuilders.get(clientType);
// Client handler returns empty content — SDK should apply defaults
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> {
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> elicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
assertThat(request.requestedSchema()).isNotNull();
return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>());
@ -491,15 +494,13 @@ public abstract class AbstractMcpClientServerIntegrationTests {
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
.callHandler((exchange, request) -> {
var elicitationRequest = McpSchema.ElicitRequest.builder()
.message("Provide your preferences")
.requestedSchema(Map.of("type", "object", "properties",
Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age",
Map.of("type", "integer", "default", 18), "subscribe",
Map.of("type", "boolean", "default", true), "color",
Map.of("type", "string", "enum", List.of("red", "green"), "default", "green")),
"required", List.of("nickname", "age", "subscribe", "color")))
var elicitationRequest = McpSchema.ElicitFormRequest.builder("Provide your preferences",
Map.of("type", "object", "properties",
Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age",
Map.of("type", "integer", "default", 18), "subscribe",
Map.of("type", "boolean", "default", true), "color",
Map.of("type", "string", "enum", List.of("red", "green"), "default", "green")),
"required", List.of("nickname", "age", "subscribe", "color")))
.build();
return exchange.createElicitation(elicitationRequest)
@ -544,7 +545,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
// Client handler returns an unmodifiable map (Map.of()) — SDK must copy into a
// mutable map before applying defaults.
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.ACCEPT, Map.of());
CallToolResult callResponse = McpSchema.CallToolResult.builder()
@ -557,9 +558,8 @@ public abstract class AbstractMcpClientServerIntegrationTests {
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
.callHandler((exchange, request) -> {
var elicitationRequest = McpSchema.ElicitRequest.builder()
.message("Provide your preferences")
.requestedSchema(Map.of("type", "object", "properties",
var elicitationRequest = McpSchema.ElicitFormRequest
.builder("Provide your preferences", Map.of("type", "object", "properties",
Map.of("nickname", Map.of("type", "string", "default", "Guest"), "age",
Map.of("type", "integer", "default", 18)),
"required", List.of("nickname", "age")))
@ -603,7 +603,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
var clientBuilder = clientBuilders.get(clientType);
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>());
CallToolResult callResponse = McpSchema.CallToolResult.builder()
@ -616,10 +616,8 @@ public abstract class AbstractMcpClientServerIntegrationTests {
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
.callHandler((exchange, request) -> {
var elicitationRequest = McpSchema.ElicitRequest.builder()
.message("Provide your preferences")
.requestedSchema(Map.of("type", "object", "properties",
Map.of("nickname", Map.of("type", "string", "default", "Guest"))))
var elicitationRequest = McpSchema.ElicitFormRequest.builder("Provide your preferences", Map.of("type",
"object", "properties", Map.of("nickname", Map.of("type", "string", "default", "Guest"))))
.build();
return exchange.createElicitation(elicitationRequest)
@ -659,7 +657,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
var clientBuilder = clientBuilders.get(clientType);
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.DECLINE, new HashMap<>());
CallToolResult callResponse = McpSchema.CallToolResult.builder()
@ -672,10 +670,8 @@ public abstract class AbstractMcpClientServerIntegrationTests {
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
.callHandler((exchange, request) -> {
var elicitationRequest = McpSchema.ElicitRequest.builder()
.message("Provide your preferences")
.requestedSchema(Map.of("type", "object", "properties",
Map.of("nickname", Map.of("type", "string", "default", "Guest"))))
var elicitationRequest = McpSchema.ElicitFormRequest.builder("Provide your preferences", Map.of("type",
"object", "properties", Map.of("nickname", Map.of("type", "string", "default", "Guest"))))
.build();
return exchange.createElicitation(elicitationRequest)
@ -716,7 +712,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
var clientBuilder = clientBuilders.get(clientType);
Map<String, Object> meta = Map.of("trace-id", "abc-123");
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
Function<McpSchema.ElicitFormRequest, McpSchema.ElicitResult> elicitationHandler = request -> new McpSchema.ElicitResult(
McpSchema.ElicitResult.Action.ACCEPT, new HashMap<>(), meta);
CallToolResult callResponse = McpSchema.CallToolResult.builder()
@ -729,10 +725,8 @@ public abstract class AbstractMcpClientServerIntegrationTests {
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
.callHandler((exchange, request) -> {
var elicitationRequest = McpSchema.ElicitRequest.builder()
.message("Provide your preferences")
.requestedSchema(Map.of("type", "object", "properties",
Map.of("nickname", Map.of("type", "string", "default", "Guest"))))
var elicitationRequest = McpSchema.ElicitFormRequest.builder("Provide your preferences", Map.of("type",
"object", "properties", Map.of("nickname", Map.of("type", "string", "default", "Guest"))))
.build();
return exchange.createElicitation(elicitationRequest)
@ -773,9 +767,9 @@ public abstract class AbstractMcpClientServerIntegrationTests {
var clientBuilder = clientBuilders.get(clientType);
Function<ElicitRequest, ElicitResult> elicitationHandler = request -> {
Function<ElicitFormRequest, ElicitResult> elicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
assertThat(request.requestedSchema()).isNotNull();
assertThat(((McpSchema.ElicitFormRequest) request).requestedSchema()).isNotNull();
return ElicitResult.builder(ElicitResult.Action.ACCEPT)
.content(Map.of("message", request.message()))
.build();
@ -791,7 +785,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> {
var elicitationRequest = McpSchema.ElicitRequest
var elicitationRequest = McpSchema.ElicitFormRequest
.builder("Test message",
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
.build();
@ -840,7 +834,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
var clientBuilder = clientBuilders.get(clientType);
Function<ElicitRequest, ElicitResult> elicitationHandler = request -> {
Function<ElicitFormRequest, ElicitResult> elicitationHandler = request -> {
assertThat(request.message()).isNotEmpty();
assertThat(request.requestedSchema()).isNotNull();
@ -867,7 +861,7 @@ public abstract class AbstractMcpClientServerIntegrationTests {
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> {
var elicitationRequest = ElicitRequest
var elicitationRequest = ElicitFormRequest
.builder("Test message",
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
.build();
@ -904,6 +898,165 @@ public abstract class AbstractMcpClientServerIntegrationTests {
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@MethodSource("clientsForTesting")
void testCreateUrlElicitationSuccess(String clientType) {
var elicitationRequest = McpSchema.ElicitUrlRequest
.builder("Test message", "https://example.com/auth", "elicitation-123")
.build();
var clientBuilder = clientBuilders.get(clientType);
Function<McpSchema.ElicitUrlRequest, McpSchema.ElicitResult> urlElicitationHandler = request -> {
assertThat(request.message()).isEqualTo("Test message");
assertThat(request.url()).isEqualTo("https://example.com/auth");
assertThat(request.elicitationId()).isEqualTo("elicitation-123");
return McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build();
};
CallToolResult callResponse = McpSchema.CallToolResult.builder()
.addContent(McpSchema.TextContent.builder("CALL RESPONSE").build())
.build();
AtomicReference<McpSchema.ElicitResult> elicitResultRef = new AtomicReference<>();
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> exchange.createElicitation(elicitationRequest)
.doOnNext(elicitResultRef::set)
.thenReturn(callResponse))
.build();
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
try (var mcpClient = clientBuilder
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.capabilities(ClientCapabilities.builder().elicitation(false, true).build())
.urlElicitation(urlElicitationHandler)
.build()) {
CallToolResult response = mcpClient
.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build());
assertThat(response).isNotNull();
assertThat(response).isEqualTo(callResponse);
var elicitResult = elicitResultRef.get();
assertThat(elicitResult).isNotNull();
assertThat(elicitResult.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
}
finally {
mcpServer.closeGracefully().block();
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@MethodSource("clientsForTesting")
void testElicitationCompleteNotification(String clientType) throws InterruptedException {
var clientBuilder = clientBuilders.get(clientType);
CountDownLatch notificationLatch = new CountDownLatch(1);
AtomicReference<McpSchema.ElicitationCompleteNotification> notificationRef = new AtomicReference<>();
AtomicReference<String> sessionId = new AtomicReference<>();
Consumer<McpSchema.ElicitationCompleteNotification> elicitationCompleteConsumer = notification -> {
notificationRef.set(notification);
notificationLatch.countDown();
};
CallToolResult callResponse = McpSchema.CallToolResult.builder()
.addContent(McpSchema.TextContent.builder("CALL RESPONSE").build())
.build();
// Capture the session ID so we can trigger an "elicitation complete" notification
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> {
sessionId.set(exchange.sessionId());
return Mono.just(callResponse);
})
.build();
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
try (var mcpClient = clientBuilder
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.elicitationCompleteConsumer(elicitationCompleteConsumer)
// enable elicitation so that we can register an elicitation complete consumer
.urlElicitation(request -> McpSchema.ElicitResult.builder(McpSchema.ElicitResult.Action.ACCEPT).build())
.build()) {
var response = mcpClient.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build());
var capturedSessionId = sessionId.get();
assertThat(response).isNotNull();
assertThat(capturedSessionId).isNotNull();
mcpServer
.sendElicitationComplete(capturedSessionId,
new McpSchema.ElicitationCompleteNotification("elicitation-123"))
.block();
assertThat(notificationLatch.await(5, TimeUnit.SECONDS)).isTrue();
var notification = notificationRef.get();
assertThat(notification).isNotNull();
assertThat(notification.elicitationId()).isEqualTo("elicitation-123");
}
finally {
mcpServer.closeGracefully().block();
}
}
@ParameterizedTest(name = "{0} : {displayName} ")
@MethodSource("clientsForTesting")
void testElicitationRequiredError(String clientType) throws InterruptedException {
var clientBuilder = clientBuilders.get(clientType);
// Capture the session ID so we can trigger an "elicitation complete" notification
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
.tool(Tool.builder("tool1", EMPTY_JSON_SCHEMA).description("tool1 description").build())
.callHandler((exchange, request) -> {
return Mono.error(McpError.URL_ELICITATION_REQUIRED.apply(List
.of(McpSchema.ElicitUrlRequest.builder("do the thing", "https://example.com", "elicitation-1234")
.build())));
})
.build();
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
Function<McpSchema.ElicitUrlRequest, ElicitResult> elicitationHandler = request -> ElicitResult
.builder(ElicitResult.Action.ACCEPT)
.build();
try (var mcpClient = clientBuilder
.clientInfo(McpSchema.Implementation.builder("Sample client", "0.0.0").build())
.urlElicitation(elicitationHandler)
.build()) {
assertThatThrownBy(
() -> mcpClient.callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()))
.isInstanceOf(McpError.class)
.extracting("jsonRpcError")
.asInstanceOf(type(McpSchema.JSONRPCResponse.JSONRPCError.class))
.satisfies(error -> {
assertThat(error.code()).isEqualTo(McpSchema.ErrorCodes.URL_ELICITATION_REQUIRED);
assertThat(error.data()).asInstanceOf(MAP)
.hasSize(1)
.extracting("elicitations")
.asInstanceOf(LIST)
.hasSize(1)
.first()
.asInstanceOf(MAP)
.containsEntry("mode", "url")
.containsEntry("message", "do the thing")
.containsEntry("url", "https://example.com")
.containsEntry("elicitationId", "elicitation-1234");
});
}
finally {
mcpServer.closeGracefully().block();
}
}
// ---------------------------------------
// Roots Tests
// ---------------------------------------

View file

@ -23,6 +23,7 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import io.modelcontextprotocol.spec.McpSchema.ElicitFormRequest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
@ -685,11 +686,13 @@ public abstract class AbstractMcpAsyncClientTests {
Function<CreateMessageRequest, Mono<CreateMessageResult>> samplingHandler = request -> Mono
.just(CreateMessageResult.builder(McpSchema.Role.ASSISTANT, "test", "test-model").build());
Function<ElicitRequest, Mono<ElicitResult>> elicitationHandler = request -> Mono
Function<ElicitFormRequest, Mono<ElicitResult>> formElicitationHandler = request -> Mono
.just(ElicitResult.builder(ElicitResult.Action.ACCEPT).content(Map.of("foo", "bar")).build());
withClient(createMcpTransport(),
builder -> builder.capabilities(capabilities).sampling(samplingHandler).elicitation(elicitationHandler),
builder -> builder.capabilities(capabilities)
.sampling(samplingHandler)
.elicitation(formElicitationHandler),
client ->
StepVerifier.create(client.initialize()).assertNext(result -> {

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