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:
parent
c49a994463
commit
dbb9bdad51
5 changed files with 1413 additions and 196 deletions
|
|
@ -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
|
||||
// ---------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue