feat: Add elicitation support to MCP protocol
Implement elicitation capabilities allowing servers to request additional information from users through clients during interactions. This feature provides a standardized way for servers to gather necessary information dynamically while clients maintain control over user interactions and data sharing. - Add ElicitRequest and ElicitResult classes to McpSchema - Implement elicitation handlers in client classes - Add elicitation capabilities to server exchange classes - Add tests for elicitation functionality with various scenarios
This commit is contained in:
parent
07e7b8fd6b
commit
8a5a591d39
2 changed files with 1106 additions and 28 deletions
|
|
@ -4,7 +4,6 @@
|
|||
package io.modelcontextprotocol;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
|
@ -28,11 +27,11 @@ import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvide
|
|||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.*;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities.CompletionCapabilities;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.netty.DisposableServer;
|
||||
import reactor.netty.http.server.HttpServer;
|
||||
|
||||
|
|
@ -41,6 +40,7 @@ import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
|||
import org.springframework.web.client.RestClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
|
@ -331,6 +331,226 @@ class WebFluxSseIntegrationTests {
|
|||
mcpServer.closeGracefully().block();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Elicitation Tests
|
||||
// ---------------------------------------
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testCreateElicitationWithoutElicitationCapabilities(String clientType) {
|
||||
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
|
||||
|
||||
exchange.createElicitation(mock(ElicitRequest.class)).block();
|
||||
|
||||
return Mono.just(mock(CallToolResult.class));
|
||||
});
|
||||
|
||||
var server = McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
|
||||
|
||||
try (
|
||||
// Create client without elicitation capabilities
|
||||
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build()) {
|
||||
|
||||
assertThat(client.initialize()).isNotNull();
|
||||
|
||||
try {
|
||||
client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
||||
}
|
||||
catch (McpError e) {
|
||||
assertThat(e).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be configured with elicitation capabilities");
|
||||
}
|
||||
}
|
||||
server.closeGracefully().block();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testCreateElicitationSuccess(String clientType) {
|
||||
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
Function<ElicitRequest, ElicitResult> elicitationHandler = request -> {
|
||||
assertThat(request.message()).isNotEmpty();
|
||||
assertThat(request.requestedSchema()).isNotNull();
|
||||
|
||||
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message()));
|
||||
};
|
||||
|
||||
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
|
||||
null);
|
||||
|
||||
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
|
||||
|
||||
var elicitationRequest = ElicitRequest.builder()
|
||||
.message("Test message")
|
||||
.requestedSchema(
|
||||
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
|
||||
.build();
|
||||
|
||||
StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
|
||||
assertThat(result.content().get("message")).isEqualTo("Test message");
|
||||
}).verifyComplete();
|
||||
|
||||
return Mono.just(callResponse);
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.async(mcpServerTransportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
||||
.capabilities(ClientCapabilities.builder().elicitation().build())
|
||||
.elicitation(elicitationHandler)
|
||||
.build()) {
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response).isEqualTo(callResponse);
|
||||
}
|
||||
mcpServer.closeGracefully().block();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testCreateElicitationWithRequestTimeoutSuccess(String clientType) {
|
||||
|
||||
// Client
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
Function<ElicitRequest, ElicitResult> elicitationHandler = request -> {
|
||||
assertThat(request.message()).isNotEmpty();
|
||||
assertThat(request.requestedSchema()).isNotNull();
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(2);
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message()));
|
||||
};
|
||||
|
||||
var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
||||
.capabilities(ClientCapabilities.builder().elicitation().build())
|
||||
.elicitation(elicitationHandler)
|
||||
.build();
|
||||
|
||||
// Server
|
||||
|
||||
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
|
||||
null);
|
||||
|
||||
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
|
||||
|
||||
var elicitationRequest = ElicitRequest.builder()
|
||||
.message("Test message")
|
||||
.requestedSchema(
|
||||
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
|
||||
.build();
|
||||
|
||||
StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
|
||||
assertThat(result.content().get("message")).isEqualTo("Test message");
|
||||
}).verifyComplete();
|
||||
|
||||
return Mono.just(callResponse);
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.async(mcpServerTransportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.requestTimeout(Duration.ofSeconds(3))
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response).isEqualTo(callResponse);
|
||||
|
||||
mcpClient.closeGracefully();
|
||||
mcpServer.closeGracefully().block();
|
||||
}
|
||||
|
||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||
@ValueSource(strings = { "httpclient", "webflux" })
|
||||
void testCreateElicitationWithRequestTimeoutFail(String clientType) {
|
||||
|
||||
// Client
|
||||
var clientBuilder = clientBuilders.get(clientType);
|
||||
|
||||
Function<ElicitRequest, ElicitResult> elicitationHandler = request -> {
|
||||
assertThat(request.message()).isNotEmpty();
|
||||
assertThat(request.requestedSchema()).isNotNull();
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(2);
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message()));
|
||||
};
|
||||
|
||||
var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
||||
.capabilities(ClientCapabilities.builder().elicitation().build())
|
||||
.elicitation(elicitationHandler)
|
||||
.build();
|
||||
|
||||
// Server
|
||||
|
||||
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
|
||||
null);
|
||||
|
||||
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
|
||||
|
||||
var elicitationRequest = ElicitRequest.builder()
|
||||
.message("Test message")
|
||||
.requestedSchema(
|
||||
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
|
||||
.build();
|
||||
|
||||
StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.action()).isEqualTo(ElicitResult.Action.ACCEPT);
|
||||
assertThat(result.content().get("message")).isEqualTo("Test message");
|
||||
}).verifyComplete();
|
||||
|
||||
return Mono.just(callResponse);
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.async(mcpServerTransportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.requestTimeout(Duration.ofSeconds(1))
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
assertThatExceptionOfType(McpError.class).isThrownBy(() -> {
|
||||
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
||||
}).withMessageContaining("within 1000ms");
|
||||
|
||||
mcpClient.closeGracefully();
|
||||
mcpServer.closeGracefully().block();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Roots Tests
|
||||
// ---------------------------------------
|
||||
|
|
|
|||
|
|
@ -357,6 +357,219 @@ class WebMvcSseIntegrationTests {
|
|||
mcpServer.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Elicitation Tests
|
||||
// ---------------------------------------
|
||||
@Test
|
||||
void testCreateElicitationWithoutElicitationCapabilities() {
|
||||
|
||||
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
|
||||
|
||||
exchange.createElicitation(mock(McpSchema.ElicitRequest.class)).block();
|
||||
|
||||
return Mono.just(mock(CallToolResult.class));
|
||||
});
|
||||
|
||||
var server = McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").tools(tool).build();
|
||||
|
||||
try (
|
||||
// Create client without elicitation capabilities
|
||||
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0")).build()) {
|
||||
|
||||
assertThat(client.initialize()).isNotNull();
|
||||
|
||||
try {
|
||||
client.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
||||
}
|
||||
catch (McpError e) {
|
||||
assertThat(e).isInstanceOf(McpError.class)
|
||||
.hasMessage("Client must be configured with elicitation capabilities");
|
||||
}
|
||||
}
|
||||
server.closeGracefully().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateElicitationSuccess() {
|
||||
|
||||
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> {
|
||||
assertThat(request.message()).isNotEmpty();
|
||||
assertThat(request.requestedSchema()).isNotNull();
|
||||
|
||||
return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT,
|
||||
Map.of("message", request.message()));
|
||||
};
|
||||
|
||||
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
|
||||
null);
|
||||
|
||||
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
|
||||
|
||||
var elicitationRequest = McpSchema.ElicitRequest.builder()
|
||||
.message("Test message")
|
||||
.requestedSchema(
|
||||
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
|
||||
.build();
|
||||
|
||||
StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
|
||||
assertThat(result.content().get("message")).isEqualTo("Test message");
|
||||
}).verifyComplete();
|
||||
|
||||
return Mono.just(callResponse);
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.async(mcpServerTransportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
||||
.capabilities(ClientCapabilities.builder().elicitation().build())
|
||||
.elicitation(elicitationHandler)
|
||||
.build()) {
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response).isEqualTo(callResponse);
|
||||
}
|
||||
mcpServer.closeGracefully().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateElicitationWithRequestTimeoutSuccess() {
|
||||
|
||||
// Client
|
||||
|
||||
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> {
|
||||
assertThat(request.message()).isNotEmpty();
|
||||
assertThat(request.requestedSchema()).isNotNull();
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(2);
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT,
|
||||
Map.of("message", request.message()));
|
||||
};
|
||||
|
||||
var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
||||
.capabilities(ClientCapabilities.builder().elicitation().build())
|
||||
.elicitation(elicitationHandler)
|
||||
.build();
|
||||
|
||||
// Server
|
||||
|
||||
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
|
||||
null);
|
||||
|
||||
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
|
||||
|
||||
var elicitationRequest = McpSchema.ElicitRequest.builder()
|
||||
.message("Test message")
|
||||
.requestedSchema(
|
||||
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
|
||||
.build();
|
||||
|
||||
StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
|
||||
assertThat(result.content().get("message")).isEqualTo("Test message");
|
||||
}).verifyComplete();
|
||||
|
||||
return Mono.just(callResponse);
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.async(mcpServerTransportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.requestTimeout(Duration.ofSeconds(3))
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
||||
|
||||
assertThat(response).isNotNull();
|
||||
assertThat(response).isEqualTo(callResponse);
|
||||
|
||||
mcpClient.closeGracefully();
|
||||
mcpServer.closeGracefully().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateElicitationWithRequestTimeoutFail() {
|
||||
|
||||
// Client
|
||||
|
||||
Function<McpSchema.ElicitRequest, McpSchema.ElicitResult> elicitationHandler = request -> {
|
||||
assertThat(request.message()).isNotEmpty();
|
||||
assertThat(request.requestedSchema()).isNotNull();
|
||||
try {
|
||||
TimeUnit.SECONDS.sleep(2);
|
||||
}
|
||||
catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return new McpSchema.ElicitResult(McpSchema.ElicitResult.Action.ACCEPT,
|
||||
Map.of("message", request.message()));
|
||||
};
|
||||
|
||||
var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
||||
.capabilities(ClientCapabilities.builder().elicitation().build())
|
||||
.elicitation(elicitationHandler)
|
||||
.build();
|
||||
|
||||
// Server
|
||||
|
||||
CallToolResult callResponse = new McpSchema.CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")),
|
||||
null);
|
||||
|
||||
McpServerFeatures.AsyncToolSpecification tool = new McpServerFeatures.AsyncToolSpecification(
|
||||
new McpSchema.Tool("tool1", "tool1 description", emptyJsonSchema), (exchange, request) -> {
|
||||
|
||||
var elicitationRequest = McpSchema.ElicitRequest.builder()
|
||||
.message("Test message")
|
||||
.requestedSchema(
|
||||
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
|
||||
.build();
|
||||
|
||||
StepVerifier.create(exchange.createElicitation(elicitationRequest)).consumeNextWith(result -> {
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
|
||||
assertThat(result.content().get("message")).isEqualTo("Test message");
|
||||
}).verifyComplete();
|
||||
|
||||
return Mono.just(callResponse);
|
||||
});
|
||||
|
||||
var mcpServer = McpServer.async(mcpServerTransportProvider)
|
||||
.serverInfo("test-server", "1.0.0")
|
||||
.requestTimeout(Duration.ofSeconds(1))
|
||||
.tools(tool)
|
||||
.build();
|
||||
|
||||
InitializeResult initResult = mcpClient.initialize();
|
||||
assertThat(initResult).isNotNull();
|
||||
|
||||
assertThatExceptionOfType(McpError.class).isThrownBy(() -> {
|
||||
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
||||
}).withMessageContaining("Timeout");
|
||||
|
||||
mcpClient.closeGracefully();
|
||||
mcpServer.closeGracefully().block();
|
||||
}
|
||||
|
||||
// ---------------------------------------
|
||||
// Roots Tests
|
||||
// ---------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in a new issue