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