- Tests are not in the abstract base class because the spring transports are not vulnerable Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
1762 lines
63 KiB
Java
1762 lines
63 KiB
Java
/*
|
|
* Copyright 2024 - 2024 the original author or authors.
|
|
*/
|
|
|
|
package io.modelcontextprotocol;
|
|
|
|
import java.net.URI;
|
|
import java.net.http.HttpClient;
|
|
import java.net.http.HttpRequest;
|
|
import java.net.http.HttpResponse;
|
|
import java.time.Duration;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
import java.util.concurrent.CopyOnWriteArrayList;
|
|
import java.util.concurrent.CountDownLatch;
|
|
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.Function;
|
|
import java.util.stream.Collectors;
|
|
|
|
import io.modelcontextprotocol.client.McpClient;
|
|
import io.modelcontextprotocol.common.McpTransportContext;
|
|
import io.modelcontextprotocol.server.McpServer;
|
|
import io.modelcontextprotocol.server.McpServerFeatures;
|
|
import io.modelcontextprotocol.server.McpSyncServer;
|
|
import io.modelcontextprotocol.server.McpSyncServerExchange;
|
|
import io.modelcontextprotocol.spec.McpError;
|
|
import io.modelcontextprotocol.spec.McpSchema;
|
|
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
|
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
|
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.ElicitResult;
|
|
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
|
|
import io.modelcontextprotocol.spec.McpSchema.ModelPreferences;
|
|
import io.modelcontextprotocol.spec.McpSchema.Prompt;
|
|
import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
|
|
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
|
|
import io.modelcontextprotocol.spec.McpSchema.Role;
|
|
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.Utils;
|
|
import net.javacrumbs.jsonunit.core.Option;
|
|
import org.junit.jupiter.params.ParameterizedTest;
|
|
import org.junit.jupiter.params.provider.MethodSource;
|
|
import org.junit.jupiter.params.provider.ValueSource;
|
|
import reactor.core.publisher.Mono;
|
|
import reactor.test.StepVerifier;
|
|
|
|
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.assertWith;
|
|
import static org.awaitility.Awaitility.await;
|
|
import static org.mockito.Mockito.mock;
|
|
|
|
public abstract class AbstractMcpClientServerIntegrationTests {
|
|
|
|
protected ConcurrentHashMap<String, McpClient.SyncSpec> clientBuilders = new ConcurrentHashMap<>();
|
|
|
|
abstract protected void prepareClients(int port, String mcpEndpoint);
|
|
|
|
protected static final int MAX_REQUEST_SIZE = 2048;
|
|
|
|
abstract protected McpServer.AsyncSpecification<?> prepareAsyncServerBuilder();
|
|
|
|
abstract protected McpServer.SyncSpecification<?> prepareSyncServerBuilder();
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void simple(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.requestTimeout(Duration.ofSeconds(1000))
|
|
.build();
|
|
try (
|
|
// Create client without sampling capabilities
|
|
var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0"))
|
|
.requestTimeout(Duration.ofSeconds(1000))
|
|
.build()) {
|
|
|
|
assertThat(client.initialize()).isNotNull();
|
|
|
|
}
|
|
finally {
|
|
server.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------
|
|
// Sampling Tests
|
|
// ---------------------------------------
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testCreateMessageWithoutSamplingCapabilities(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> {
|
|
return exchange.createMessage(mock(McpSchema.CreateMessageRequest.class))
|
|
.then(Mono.just(mock(CallToolResult.class)));
|
|
})
|
|
.build();
|
|
|
|
var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
|
|
|
|
try (
|
|
// Create client without sampling 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 sampling capabilities");
|
|
}
|
|
}
|
|
finally {
|
|
server.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testCreateMessageSuccess(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
|
|
assertThat(request.messages()).hasSize(1);
|
|
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
|
|
|
|
return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName",
|
|
CreateMessageResult.StopReason.STOP_SEQUENCE);
|
|
};
|
|
|
|
CallToolResult callResponse = McpSchema.CallToolResult.builder()
|
|
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
|
|
.build();
|
|
|
|
AtomicReference<CreateMessageResult> samplingResult = new AtomicReference<>();
|
|
|
|
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
var createMessageRequest = McpSchema.CreateMessageRequest.builder()
|
|
.messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER,
|
|
new McpSchema.TextContent("Test message"))))
|
|
.modelPreferences(ModelPreferences.builder()
|
|
.hints(List.of())
|
|
.costPriority(1.0)
|
|
.speedPriority(1.0)
|
|
.intelligencePriority(1.0)
|
|
.build())
|
|
.build();
|
|
|
|
return exchange.createMessage(createMessageRequest)
|
|
.doOnNext(samplingResult::set)
|
|
.thenReturn(callResponse);
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareAsyncServerBuilder().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().sampling().build())
|
|
.sampling(samplingHandler)
|
|
.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);
|
|
|
|
assertWith(samplingResult.get(), result -> {
|
|
assertThat(result).isNotNull();
|
|
assertThat(result.role()).isEqualTo(Role.USER);
|
|
assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class);
|
|
assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message");
|
|
assertThat(result.model()).isEqualTo("MockModelName");
|
|
assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE);
|
|
});
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testCreateMessageWithRequestTimeoutSuccess(String clientType) throws InterruptedException {
|
|
|
|
// Client
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
|
|
assertThat(request.messages()).hasSize(1);
|
|
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
|
|
try {
|
|
TimeUnit.SECONDS.sleep(2);
|
|
}
|
|
catch (InterruptedException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName",
|
|
CreateMessageResult.StopReason.STOP_SEQUENCE);
|
|
};
|
|
|
|
// Server
|
|
|
|
CallToolResult callResponse = McpSchema.CallToolResult.builder()
|
|
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
|
|
.build();
|
|
|
|
AtomicReference<CreateMessageResult> samplingResult = new AtomicReference<>();
|
|
|
|
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
var createMessageRequest = McpSchema.CreateMessageRequest.builder()
|
|
.messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER,
|
|
new McpSchema.TextContent("Test message"))))
|
|
.modelPreferences(ModelPreferences.builder()
|
|
.hints(List.of())
|
|
.costPriority(1.0)
|
|
.speedPriority(1.0)
|
|
.intelligencePriority(1.0)
|
|
.build())
|
|
.build();
|
|
|
|
return exchange.createMessage(createMessageRequest)
|
|
.doOnNext(samplingResult::set)
|
|
.thenReturn(callResponse);
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.requestTimeout(Duration.ofSeconds(4))
|
|
.tools(tool)
|
|
.build();
|
|
try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
|
.capabilities(ClientCapabilities.builder().sampling().build())
|
|
.sampling(samplingHandler)
|
|
.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);
|
|
|
|
assertWith(samplingResult.get(), result -> {
|
|
assertThat(result).isNotNull();
|
|
assertThat(result.role()).isEqualTo(Role.USER);
|
|
assertThat(result.content()).isInstanceOf(McpSchema.TextContent.class);
|
|
assertThat(((McpSchema.TextContent) result.content()).text()).isEqualTo("Test message");
|
|
assertThat(result.model()).isEqualTo("MockModelName");
|
|
assertThat(result.stopReason()).isEqualTo(CreateMessageResult.StopReason.STOP_SEQUENCE);
|
|
});
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testCreateMessageWithRequestTimeoutFail(String clientType) throws InterruptedException {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
Function<CreateMessageRequest, CreateMessageResult> samplingHandler = request -> {
|
|
assertThat(request.messages()).hasSize(1);
|
|
assertThat(request.messages().get(0).content()).isInstanceOf(McpSchema.TextContent.class);
|
|
try {
|
|
TimeUnit.SECONDS.sleep(2);
|
|
}
|
|
catch (InterruptedException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
return new CreateMessageResult(Role.USER, new McpSchema.TextContent("Test message"), "MockModelName",
|
|
CreateMessageResult.StopReason.STOP_SEQUENCE);
|
|
};
|
|
|
|
CallToolResult callResponse = McpSchema.CallToolResult.builder()
|
|
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
|
|
.build();
|
|
|
|
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
var createMessageRequest = McpSchema.CreateMessageRequest.builder()
|
|
.messages(List.of(new McpSchema.SamplingMessage(McpSchema.Role.USER,
|
|
new McpSchema.TextContent("Test message"))))
|
|
.modelPreferences(ModelPreferences.builder()
|
|
.hints(List.of())
|
|
.costPriority(1.0)
|
|
.speedPriority(1.0)
|
|
.intelligencePriority(1.0)
|
|
.build())
|
|
.build();
|
|
|
|
return exchange.createMessage(createMessageRequest).thenReturn(callResponse);
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.requestTimeout(Duration.ofSeconds(1))
|
|
.tools(tool)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.clientInfo(new McpSchema.Implementation("Sample client", "0.0.0"))
|
|
.capabilities(ClientCapabilities.builder().sampling().build())
|
|
.sampling(samplingHandler)
|
|
.build()) {
|
|
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
assertThatExceptionOfType(McpError.class).isThrownBy(() -> {
|
|
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
|
}).withMessageContaining("1000ms");
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------
|
|
// Elicitation Tests
|
|
// ---------------------------------------
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testCreateElicitationWithoutElicitationCapabilities(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> exchange.createElicitation(mock(ElicitRequest.class))
|
|
.then(Mono.just(mock(CallToolResult.class))))
|
|
.build();
|
|
|
|
var server = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0").tools(tool).build();
|
|
|
|
// Create client without elicitation capabilities
|
|
try (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");
|
|
}
|
|
}
|
|
finally {
|
|
server.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testCreateElicitationSuccess(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
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 = McpSchema.CallToolResult.builder()
|
|
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
|
|
.build();
|
|
|
|
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("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);
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareAsyncServerBuilder().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);
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testCreateElicitationWithRequestTimeoutSuccess(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 = McpSchema.CallToolResult.builder()
|
|
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
|
|
.build();
|
|
|
|
AtomicReference<ElicitResult> resultRef = new AtomicReference<>();
|
|
|
|
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("Test message")
|
|
.requestedSchema(
|
|
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
|
|
.build();
|
|
|
|
return exchange.createElicitation(elicitationRequest)
|
|
.doOnNext(resultRef::set)
|
|
.then(Mono.just(callResponse));
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.requestTimeout(Duration.ofSeconds(3))
|
|
.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);
|
|
assertWith(resultRef.get(), result -> {
|
|
assertThat(result).isNotNull();
|
|
assertThat(result.action()).isEqualTo(McpSchema.ElicitResult.Action.ACCEPT);
|
|
assertThat(result.content().get("message")).isEqualTo("Test message");
|
|
});
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testCreateElicitationWithRequestTimeoutFail(String clientType) {
|
|
|
|
var latch = new CountDownLatch(1);
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
Function<ElicitRequest, ElicitResult> elicitationHandler = request -> {
|
|
assertThat(request.message()).isNotEmpty();
|
|
assertThat(request.requestedSchema()).isNotNull();
|
|
|
|
try {
|
|
if (!latch.await(2, TimeUnit.SECONDS)) {
|
|
throw new RuntimeException("Timeout waiting for elicitation processing");
|
|
}
|
|
}
|
|
catch (InterruptedException e) {
|
|
throw new RuntimeException(e);
|
|
}
|
|
return new ElicitResult(ElicitResult.Action.ACCEPT, Map.of("message", request.message()));
|
|
};
|
|
|
|
CallToolResult callResponse = CallToolResult.builder().addContent(new TextContent("CALL RESPONSE")).build();
|
|
|
|
AtomicReference<ElicitResult> resultRef = new AtomicReference<>();
|
|
|
|
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
var elicitationRequest = ElicitRequest.builder()
|
|
.message("Test message")
|
|
.requestedSchema(
|
|
Map.of("type", "object", "properties", Map.of("message", Map.of("type", "string"))))
|
|
.build();
|
|
|
|
return exchange.createElicitation(elicitationRequest)
|
|
.doOnNext(resultRef::set)
|
|
.then(Mono.just(callResponse));
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.requestTimeout(Duration.ofSeconds(1)) // 1 second.
|
|
.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();
|
|
|
|
assertThatExceptionOfType(McpError.class).isThrownBy(() -> {
|
|
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
|
}).withMessageContaining("within 1000ms");
|
|
|
|
ElicitResult elicitResult = resultRef.get();
|
|
assertThat(elicitResult).isNull();
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------
|
|
// Roots Tests
|
|
// ---------------------------------------
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testRootsSuccess(String clientType) {
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
List<Root> roots = List.of(new Root("uri1://", "root1"), new Root("uri2://", "root2"));
|
|
|
|
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
|
|
|
|
var mcpServer = prepareSyncServerBuilder()
|
|
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
|
.roots(roots)
|
|
.build()) {
|
|
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
assertThat(rootsRef.get()).isNull();
|
|
|
|
mcpClient.rootsListChangedNotification();
|
|
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(rootsRef.get()).containsAll(roots);
|
|
});
|
|
|
|
// Remove a root
|
|
mcpClient.removeRoot(roots.get(0).uri());
|
|
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1)));
|
|
});
|
|
|
|
// Add a new root
|
|
var root3 = new Root("uri3://", "root3");
|
|
mcpClient.addRoot(root3);
|
|
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(rootsRef.get()).containsAll(List.of(roots.get(1), root3));
|
|
});
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testRootsWithoutCapability(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
exchange.listRoots(); // try to list roots
|
|
|
|
return mock(CallToolResult.class);
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareSyncServerBuilder().rootsChangeHandler((exchange, rootsUpdate) -> {
|
|
}).tools(tool).build();
|
|
|
|
try (
|
|
// Create client without roots capability
|
|
// No roots capability
|
|
var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().build()).build()) {
|
|
|
|
assertThat(mcpClient.initialize()).isNotNull();
|
|
|
|
// Attempt to list roots should fail
|
|
try {
|
|
mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
|
}
|
|
catch (McpError e) {
|
|
assertThat(e).isInstanceOf(McpError.class).hasMessage("Roots not supported");
|
|
}
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testRootsNotificationWithEmptyRootsList(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
|
|
|
|
var mcpServer = prepareSyncServerBuilder()
|
|
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
|
.roots(List.of()) // Empty roots list
|
|
.build()) {
|
|
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
mcpClient.rootsListChangedNotification();
|
|
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(rootsRef.get()).isEmpty();
|
|
});
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testRootsWithMultipleHandlers(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
List<Root> roots = List.of(new Root("uri1://", "root1"));
|
|
|
|
AtomicReference<List<Root>> rootsRef1 = new AtomicReference<>();
|
|
AtomicReference<List<Root>> rootsRef2 = new AtomicReference<>();
|
|
|
|
var mcpServer = prepareSyncServerBuilder()
|
|
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef1.set(rootsUpdate))
|
|
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef2.set(rootsUpdate))
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
|
.roots(roots)
|
|
.build()) {
|
|
|
|
assertThat(mcpClient.initialize()).isNotNull();
|
|
|
|
mcpClient.rootsListChangedNotification();
|
|
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(rootsRef1.get()).containsAll(roots);
|
|
assertThat(rootsRef2.get()).containsAll(roots);
|
|
});
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testRootsServerCloseWithActiveSubscription(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
List<Root> roots = List.of(new Root("uri1://", "root1"));
|
|
|
|
AtomicReference<List<Root>> rootsRef = new AtomicReference<>();
|
|
|
|
var mcpServer = prepareSyncServerBuilder()
|
|
.rootsChangeHandler((exchange, rootsUpdate) -> rootsRef.set(rootsUpdate))
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.capabilities(ClientCapabilities.builder().roots(true).build())
|
|
.roots(roots)
|
|
.build()) {
|
|
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
mcpClient.rootsListChangedNotification();
|
|
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(rootsRef.get()).containsAll(roots);
|
|
});
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------
|
|
// Tools Tests
|
|
// ---------------------------------------
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testToolCallSuccess(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
var responseBodyIsNullOrBlank = new AtomicBoolean(false);
|
|
var callResponse = McpSchema.CallToolResult.builder()
|
|
.addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=importantValue"))
|
|
.build();
|
|
McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
try {
|
|
HttpResponse<String> response = HttpClient.newHttpClient()
|
|
.send(HttpRequest.newBuilder()
|
|
.uri(URI.create(
|
|
"https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md"))
|
|
.GET()
|
|
.build(), HttpResponse.BodyHandlers.ofString());
|
|
String responseBody = response.body();
|
|
responseBodyIsNullOrBlank.set(!Utils.hasText(responseBody));
|
|
}
|
|
catch (Exception e) {
|
|
e.printStackTrace();
|
|
}
|
|
|
|
return callResponse;
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool1)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
|
|
|
|
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
|
|
|
assertThat(responseBodyIsNullOrBlank.get()).isFalse();
|
|
assertThat(response).isNotNull().isEqualTo(callResponse);
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testThrowingToolCallIsCaughtBeforeTimeout(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
McpSyncServer mcpServer = prepareSyncServerBuilder()
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(Tool.builder()
|
|
.name("tool1")
|
|
.description("tool1 description")
|
|
.inputSchema(EMPTY_JSON_SCHEMA)
|
|
.build())
|
|
.callHandler((exchange, request) -> {
|
|
// We trigger a timeout on blocking read, raising an exception
|
|
Mono.never().block(Duration.ofSeconds(1));
|
|
return null;
|
|
})
|
|
.build())
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.requestTimeout(Duration.ofMillis(6666)).build()) {
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
// We expect the tool call to fail immediately with the exception raised by
|
|
// the offending tool instead of getting back a timeout.
|
|
assertThatExceptionOfType(McpError.class)
|
|
.isThrownBy(() -> mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of())))
|
|
.withMessageContaining("Timeout on blocking read");
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testToolCallSuccessWithTranportContextExtraction(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
var transportContextIsNull = new AtomicBoolean(false);
|
|
var transportContextIsEmpty = new AtomicBoolean(false);
|
|
var responseBodyIsNullOrBlank = new AtomicBoolean(false);
|
|
|
|
var expectedCallResponse = McpSchema.CallToolResult.builder()
|
|
.addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=value"))
|
|
.build();
|
|
McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
McpTransportContext transportContext = exchange.transportContext();
|
|
transportContextIsNull.set(transportContext == null);
|
|
transportContextIsEmpty.set(transportContext.equals(McpTransportContext.EMPTY));
|
|
String ctxValue = (String) transportContext.get("important");
|
|
|
|
try {
|
|
String responseBody = "TOOL RESPONSE";
|
|
responseBodyIsNullOrBlank.set(!Utils.hasText(responseBody));
|
|
}
|
|
catch (Exception e) {
|
|
e.printStackTrace();
|
|
}
|
|
|
|
return McpSchema.CallToolResult.builder()
|
|
.addContent(new McpSchema.TextContent("CALL RESPONSE; ctx=" + ctxValue))
|
|
.build();
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool1)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
|
|
|
|
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("tool1", Map.of()));
|
|
|
|
assertThat(transportContextIsNull.get()).isFalse();
|
|
assertThat(transportContextIsEmpty.get()).isFalse();
|
|
assertThat(responseBodyIsNullOrBlank.get()).isFalse();
|
|
assertThat(response).isNotNull().isEqualTo(expectedCallResponse);
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testToolListChangeHandlingSuccess(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
var callResponse = McpSchema.CallToolResult.builder()
|
|
.addContent(new McpSchema.TextContent("CALL RESPONSE"))
|
|
.build();
|
|
|
|
McpServerFeatures.SyncToolSpecification tool1 = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(Tool.builder().name("tool1").description("tool1 description").inputSchema(EMPTY_JSON_SCHEMA).build())
|
|
.callHandler((exchange, request) -> {
|
|
// perform a blocking call to a remote service
|
|
try {
|
|
HttpResponse<String> response = HttpClient.newHttpClient()
|
|
.send(HttpRequest.newBuilder()
|
|
.uri(URI.create(
|
|
"https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md"))
|
|
.GET()
|
|
.build(), HttpResponse.BodyHandlers.ofString());
|
|
String responseBody = response.body();
|
|
assertThat(responseBody).isNotBlank();
|
|
}
|
|
catch (Exception e) {
|
|
e.printStackTrace();
|
|
}
|
|
return callResponse;
|
|
})
|
|
.build();
|
|
|
|
AtomicReference<List<Tool>> toolsRef = new AtomicReference<>();
|
|
|
|
var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool1)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.toolsChangeConsumer(toolsUpdate -> {
|
|
// perform a blocking call to a remote service
|
|
try {
|
|
HttpResponse<String> response = HttpClient.newHttpClient()
|
|
.send(HttpRequest.newBuilder()
|
|
.uri(URI.create(
|
|
"https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md"))
|
|
.GET()
|
|
.build(), HttpResponse.BodyHandlers.ofString());
|
|
String responseBody = response.body();
|
|
assertThat(responseBody).isNotBlank();
|
|
toolsRef.set(toolsUpdate);
|
|
}
|
|
catch (Exception e) {
|
|
e.printStackTrace();
|
|
}
|
|
}).build()) {
|
|
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
assertThat(toolsRef.get()).isNull();
|
|
|
|
assertThat(mcpClient.listTools().tools()).contains(tool1.tool());
|
|
|
|
mcpServer.notifyToolsListChanged();
|
|
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(toolsRef.get()).containsAll(List.of(tool1.tool()));
|
|
});
|
|
|
|
// Remove a tool
|
|
mcpServer.removeTool("tool1");
|
|
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(toolsRef.get()).isEmpty();
|
|
});
|
|
|
|
// Add a new tool
|
|
McpServerFeatures.SyncToolSpecification tool2 = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(Tool.builder()
|
|
.name("tool2")
|
|
.description("tool2 description")
|
|
.inputSchema(EMPTY_JSON_SCHEMA)
|
|
.build())
|
|
.callHandler((exchange, request) -> callResponse)
|
|
.build();
|
|
|
|
mcpServer.addTool(tool2);
|
|
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(toolsRef.get()).containsAll(List.of(tool2.tool()));
|
|
});
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testInitialize(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
var mcpServer = prepareSyncServerBuilder().build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------
|
|
// Logging Tests
|
|
// ---------------------------------------
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testLoggingNotification(String clientType) throws InterruptedException {
|
|
int expectedNotificationsCount = 3;
|
|
CountDownLatch latch = new CountDownLatch(expectedNotificationsCount);
|
|
// Create a list to store received logging notifications
|
|
List<McpSchema.LoggingMessageNotification> receivedNotifications = new CopyOnWriteArrayList<>();
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
// Create server with a tool that sends logging notifications
|
|
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
|
.tool(Tool.builder()
|
|
.name("logging-test")
|
|
.description("Test logging notifications")
|
|
.inputSchema(EMPTY_JSON_SCHEMA)
|
|
.build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
// Create and send notifications with different levels
|
|
|
|
//@formatter:off
|
|
return exchange // This should be filtered out (DEBUG < NOTICE)
|
|
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
|
.level(McpSchema.LoggingLevel.DEBUG)
|
|
.logger("test-logger")
|
|
.data("Debug message")
|
|
.build())
|
|
.then(exchange // This should be sent (NOTICE >= NOTICE)
|
|
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
|
.level(McpSchema.LoggingLevel.NOTICE)
|
|
.logger("test-logger")
|
|
.data("Notice message")
|
|
.build()))
|
|
.then(exchange // This should be sent (ERROR > NOTICE)
|
|
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
|
.level(McpSchema.LoggingLevel.ERROR)
|
|
.logger("test-logger")
|
|
.data("Error message")
|
|
.build()))
|
|
.then(exchange // This should be filtered out (INFO < NOTICE)
|
|
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
|
.level(McpSchema.LoggingLevel.INFO)
|
|
.logger("test-logger")
|
|
.data("Another info message")
|
|
.build()))
|
|
.then(exchange // This should be sent (ERROR >= NOTICE)
|
|
.loggingNotification(McpSchema.LoggingMessageNotification.builder()
|
|
.level(McpSchema.LoggingLevel.ERROR)
|
|
.logger("test-logger")
|
|
.data("Another error message")
|
|
.build()))
|
|
.thenReturn(CallToolResult.builder()
|
|
.content(List.of(new McpSchema.TextContent("Logging test completed")))
|
|
.isError(false)
|
|
.build());
|
|
//@formatter:on
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool)
|
|
.build();
|
|
|
|
try (
|
|
// Create client with logging notification handler
|
|
var mcpClient = clientBuilder.loggingConsumer(notification -> {
|
|
receivedNotifications.add(notification);
|
|
latch.countDown();
|
|
}).build()) {
|
|
|
|
// Initialize client
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
// Set minimum logging level to NOTICE
|
|
mcpClient.setLoggingLevel(McpSchema.LoggingLevel.NOTICE);
|
|
|
|
// Call the tool that sends logging notifications
|
|
CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("logging-test", Map.of()));
|
|
assertThat(result).isNotNull();
|
|
assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
|
assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Logging test completed");
|
|
|
|
assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue();
|
|
|
|
// Should have received 3 notifications (1 NOTICE and 2 ERROR)
|
|
assertThat(receivedNotifications).hasSize(expectedNotificationsCount);
|
|
|
|
Map<String, McpSchema.LoggingMessageNotification> notificationMap = receivedNotifications.stream()
|
|
.collect(Collectors.toMap(n -> n.data(), n -> n));
|
|
|
|
// First notification should be NOTICE level
|
|
assertThat(notificationMap.get("Notice message").level()).isEqualTo(McpSchema.LoggingLevel.NOTICE);
|
|
assertThat(notificationMap.get("Notice message").logger()).isEqualTo("test-logger");
|
|
assertThat(notificationMap.get("Notice message").data()).isEqualTo("Notice message");
|
|
|
|
// Second notification should be ERROR level
|
|
assertThat(notificationMap.get("Error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
|
|
assertThat(notificationMap.get("Error message").logger()).isEqualTo("test-logger");
|
|
assertThat(notificationMap.get("Error message").data()).isEqualTo("Error message");
|
|
|
|
// Third notification should be ERROR level
|
|
assertThat(notificationMap.get("Another error message").level()).isEqualTo(McpSchema.LoggingLevel.ERROR);
|
|
assertThat(notificationMap.get("Another error message").logger()).isEqualTo("test-logger");
|
|
assertThat(notificationMap.get("Another error message").data()).isEqualTo("Another error message");
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------
|
|
// Progress Tests
|
|
// ---------------------------------------
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testProgressNotification(String clientType) throws InterruptedException {
|
|
int expectedNotificationsCount = 4; // 3 notifications + 1 for another progress
|
|
// token
|
|
CountDownLatch latch = new CountDownLatch(expectedNotificationsCount);
|
|
// Create a list to store received logging notifications
|
|
List<McpSchema.ProgressNotification> receivedNotifications = new CopyOnWriteArrayList<>();
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
// Create server with a tool that sends logging notifications
|
|
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
|
.tool(McpSchema.Tool.builder()
|
|
.name("progress-test")
|
|
.description("Test progress notifications")
|
|
.inputSchema(EMPTY_JSON_SCHEMA)
|
|
.build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
// Create and send notifications
|
|
var progressToken = (String) request.meta().get("progressToken");
|
|
|
|
return exchange
|
|
.progressNotification(
|
|
new McpSchema.ProgressNotification(progressToken, 0.0, 1.0, "Processing started"))
|
|
.then(exchange.progressNotification(
|
|
new McpSchema.ProgressNotification(progressToken, 0.5, 1.0, "Processing data")))
|
|
.then(// Send a progress notification with another progress value
|
|
// should
|
|
exchange.progressNotification(new McpSchema.ProgressNotification("another-progress-token",
|
|
0.0, 1.0, "Another processing started")))
|
|
.then(exchange.progressNotification(
|
|
new McpSchema.ProgressNotification(progressToken, 1.0, 1.0, "Processing completed")))
|
|
.thenReturn(CallToolResult.builder()
|
|
.content(List.of(new McpSchema.TextContent("Progress test completed")))
|
|
.isError(false)
|
|
.build());
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool)
|
|
.build();
|
|
|
|
try (
|
|
// Create client with progress notification handler
|
|
var mcpClient = clientBuilder.progressConsumer(notification -> {
|
|
receivedNotifications.add(notification);
|
|
latch.countDown();
|
|
}).build()) {
|
|
|
|
// Initialize client
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
// Call the tool that sends progress notifications
|
|
McpSchema.CallToolRequest callToolRequest = McpSchema.CallToolRequest.builder()
|
|
.name("progress-test")
|
|
.meta(Map.of("progressToken", "test-progress-token"))
|
|
.build();
|
|
CallToolResult result = mcpClient.callTool(callToolRequest);
|
|
assertThat(result).isNotNull();
|
|
assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
|
assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Progress test completed");
|
|
|
|
assertThat(latch.await(5, TimeUnit.SECONDS)).as("Should receive notifications in reasonable time").isTrue();
|
|
|
|
// Should have received 3 notifications
|
|
assertThat(receivedNotifications).hasSize(expectedNotificationsCount);
|
|
|
|
Map<String, McpSchema.ProgressNotification> notificationMap = receivedNotifications.stream()
|
|
.collect(Collectors.toMap(n -> n.message(), n -> n));
|
|
|
|
// First notification should be 0.0/1.0 progress
|
|
assertThat(notificationMap.get("Processing started").progressToken()).isEqualTo("test-progress-token");
|
|
assertThat(notificationMap.get("Processing started").progress()).isEqualTo(0.0);
|
|
assertThat(notificationMap.get("Processing started").total()).isEqualTo(1.0);
|
|
assertThat(notificationMap.get("Processing started").message()).isEqualTo("Processing started");
|
|
|
|
// Second notification should be 0.5/1.0 progress
|
|
assertThat(notificationMap.get("Processing data").progressToken()).isEqualTo("test-progress-token");
|
|
assertThat(notificationMap.get("Processing data").progress()).isEqualTo(0.5);
|
|
assertThat(notificationMap.get("Processing data").total()).isEqualTo(1.0);
|
|
assertThat(notificationMap.get("Processing data").message()).isEqualTo("Processing data");
|
|
|
|
// Third notification should be another progress token with 0.0/1.0 progress
|
|
assertThat(notificationMap.get("Another processing started").progressToken())
|
|
.isEqualTo("another-progress-token");
|
|
assertThat(notificationMap.get("Another processing started").progress()).isEqualTo(0.0);
|
|
assertThat(notificationMap.get("Another processing started").total()).isEqualTo(1.0);
|
|
assertThat(notificationMap.get("Another processing started").message())
|
|
.isEqualTo("Another processing started");
|
|
|
|
// Fourth notification should be 1.0/1.0 progress
|
|
assertThat(notificationMap.get("Processing completed").progressToken()).isEqualTo("test-progress-token");
|
|
assertThat(notificationMap.get("Processing completed").progress()).isEqualTo(1.0);
|
|
assertThat(notificationMap.get("Processing completed").total()).isEqualTo(1.0);
|
|
assertThat(notificationMap.get("Processing completed").message()).isEqualTo("Processing completed");
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------
|
|
// Completion Tests
|
|
// ---------------------------------------
|
|
@ParameterizedTest(name = "{0} : Completion call")
|
|
@MethodSource("clientsForTesting")
|
|
void testCompletionShouldReturnExpectedSuggestions(String clientType) {
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
var expectedValues = List.of("python", "pytorch", "pyside");
|
|
var completionResponse = new McpSchema.CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total
|
|
true // hasMore
|
|
));
|
|
|
|
AtomicReference<CompleteRequest> samplingRequest = new AtomicReference<>();
|
|
BiFunction<McpSyncServerExchange, CompleteRequest, CompleteResult> completionHandler = (mcpSyncServerExchange,
|
|
request) -> {
|
|
samplingRequest.set(request);
|
|
return completionResponse;
|
|
};
|
|
|
|
var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().completions().build())
|
|
.prompts(new McpServerFeatures.SyncPromptSpecification(
|
|
new Prompt("code_review", "Code review", "this is code review prompt",
|
|
List.of(new PromptArgument("language", "Language", "string", false))),
|
|
(mcpSyncServerExchange, getPromptRequest) -> null))
|
|
.completions(new McpServerFeatures.SyncCompletionSpecification(
|
|
new McpSchema.PromptReference(PromptReference.TYPE, "code_review", "Code review"),
|
|
completionHandler))
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
CompleteRequest request = new CompleteRequest(
|
|
new PromptReference(PromptReference.TYPE, "code_review", "Code review"),
|
|
new CompleteRequest.CompleteArgument("language", "py"));
|
|
|
|
CompleteResult result = mcpClient.completeCompletion(request);
|
|
|
|
assertThat(result).isNotNull();
|
|
|
|
assertThat(samplingRequest.get().argument().name()).isEqualTo("language");
|
|
assertThat(samplingRequest.get().argument().value()).isEqualTo("py");
|
|
assertThat(samplingRequest.get().ref().type()).isEqualTo(PromptReference.TYPE);
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------
|
|
// Ping Tests
|
|
// ---------------------------------------
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testPingSuccess(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
// Create server with a tool that uses ping functionality
|
|
AtomicReference<String> executionOrder = new AtomicReference<>("");
|
|
|
|
McpServerFeatures.AsyncToolSpecification tool = McpServerFeatures.AsyncToolSpecification.builder()
|
|
.tool(Tool.builder()
|
|
.name("ping-async-test")
|
|
.description("Test ping async behavior")
|
|
.inputSchema(EMPTY_JSON_SCHEMA)
|
|
.build())
|
|
.callHandler((exchange, request) -> {
|
|
|
|
executionOrder.set(executionOrder.get() + "1");
|
|
|
|
// Test async ping behavior
|
|
return exchange.ping().doOnNext(result -> {
|
|
|
|
assertThat(result).isNotNull();
|
|
// Ping should return an empty object or map
|
|
assertThat(result).isInstanceOf(Map.class);
|
|
|
|
executionOrder.set(executionOrder.get() + "2");
|
|
assertThat(result).isNotNull();
|
|
}).then(Mono.fromCallable(() -> {
|
|
executionOrder.set(executionOrder.get() + "3");
|
|
return CallToolResult.builder()
|
|
.content(List.of(new McpSchema.TextContent("Async ping test completed")))
|
|
.isError(false)
|
|
.build();
|
|
}));
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
|
|
// Initialize client
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
// Call the tool that tests ping async behavior
|
|
CallToolResult result = mcpClient.callTool(new McpSchema.CallToolRequest("ping-async-test", Map.of()));
|
|
assertThat(result).isNotNull();
|
|
assertThat(result.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
|
assertThat(((McpSchema.TextContent) result.content().get(0)).text()).isEqualTo("Async ping test completed");
|
|
|
|
// Verify execution order
|
|
assertThat(executionOrder.get()).isEqualTo("123");
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully().block();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------
|
|
// Tool Structured Output Schema Tests
|
|
// ---------------------------------------
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testStructuredOutputValidationSuccess(String clientType) {
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
// Create a tool with output schema
|
|
Map<String, Object> outputSchema = Map.of(
|
|
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
|
|
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
|
|
"required", List.of("result", "operation"));
|
|
|
|
Tool calculatorTool = Tool.builder()
|
|
.name("calculator")
|
|
.description("Performs mathematical calculations")
|
|
.outputSchema(outputSchema)
|
|
.build();
|
|
|
|
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(calculatorTool)
|
|
.callHandler((exchange, request) -> {
|
|
String expression = (String) request.arguments().getOrDefault("expression", "2 + 3");
|
|
double result = evaluateExpression(expression);
|
|
return CallToolResult.builder()
|
|
.structuredContent(
|
|
Map.of("result", result, "operation", expression, "timestamp", "2024-01-01T10:00:00Z"))
|
|
.build();
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
// Verify tool is listed with output schema
|
|
var toolsList = mcpClient.listTools();
|
|
assertThat(toolsList.tools()).hasSize(1);
|
|
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
|
|
// Note: outputSchema might be null in sync server, but validation still works
|
|
|
|
// Call tool with valid structured output
|
|
CallToolResult response = mcpClient
|
|
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
|
|
|
|
assertThat(response).isNotNull();
|
|
assertThat(response.isError()).isFalse();
|
|
|
|
// In WebMVC, structured content is returned properly
|
|
if (response.structuredContent() != null) {
|
|
assertThat((Map<String, Object>) response.structuredContent()).containsEntry("result", 5.0)
|
|
.containsEntry("operation", "2 + 3")
|
|
.containsEntry("timestamp", "2024-01-01T10:00:00Z");
|
|
}
|
|
else {
|
|
// Fallback to checking content if structured content is not available
|
|
assertThat(response.content()).isNotEmpty();
|
|
}
|
|
|
|
assertThat(response.structuredContent()).isNotNull();
|
|
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
|
|
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
|
|
.isObject()
|
|
.isEqualTo(json("""
|
|
{"result":5.0,"operation":"2 + 3","timestamp":"2024-01-01T10:00:00Z"}"""));
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@ValueSource(strings = { "httpclient" })
|
|
void testStructuredOutputOfObjectArrayValidationSuccess(String clientType) {
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
// Create a tool with output schema that returns an array of objects
|
|
Map<String, Object> outputSchema = Map
|
|
.of( // @formatter:off
|
|
"type", "array",
|
|
"items", Map.of(
|
|
"type", "object",
|
|
"properties", Map.of(
|
|
"name", Map.of("type", "string"),
|
|
"age", Map.of("type", "number")),
|
|
"required", List.of("name", "age"))); // @formatter:on
|
|
|
|
Tool calculatorTool = Tool.builder()
|
|
.name("getMembers")
|
|
.description("Returns a list of members")
|
|
.outputSchema(outputSchema)
|
|
.build();
|
|
|
|
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(calculatorTool)
|
|
.callHandler((exchange, request) -> {
|
|
return CallToolResult.builder()
|
|
.structuredContent(List.of(Map.of("name", "John", "age", 30), Map.of("name", "Peter", "age", 25)))
|
|
.build();
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
assertThat(mcpClient.initialize()).isNotNull();
|
|
|
|
// Call tool with valid structured output of type array
|
|
CallToolResult response = mcpClient.callTool(new McpSchema.CallToolRequest("getMembers", Map.of()));
|
|
|
|
assertThat(response).isNotNull();
|
|
assertThat(response.isError()).isFalse();
|
|
|
|
assertThat(response.structuredContent()).isNotNull();
|
|
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
|
|
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
|
|
.isArray()
|
|
.hasSize(2)
|
|
.containsExactlyInAnyOrder(json("""
|
|
{"name":"John","age":30}"""), json("""
|
|
{"name":"Peter","age":25}"""));
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@ValueSource(strings = { "httpclient" })
|
|
void testStructuredOutputWithInHandlerError(String clientType) {
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
// Create a tool with output schema
|
|
Map<String, Object> outputSchema = Map.of(
|
|
"type", "object", "properties", Map.of("result", Map.of("type", "number"), "operation",
|
|
Map.of("type", "string"), "timestamp", Map.of("type", "string")),
|
|
"required", List.of("result", "operation"));
|
|
|
|
Tool calculatorTool = Tool.builder()
|
|
.name("calculator")
|
|
.description("Performs mathematical calculations")
|
|
.outputSchema(outputSchema)
|
|
.build();
|
|
|
|
// Handler that returns an error result
|
|
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(calculatorTool)
|
|
.callHandler((exchange, request) -> CallToolResult.builder()
|
|
.isError(true)
|
|
.content(List.of(new TextContent("Error calling tool: Simulated in-handler error")))
|
|
.build())
|
|
.build();
|
|
|
|
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
// Verify tool is listed with output schema
|
|
var toolsList = mcpClient.listTools();
|
|
assertThat(toolsList.tools()).hasSize(1);
|
|
assertThat(toolsList.tools().get(0).name()).isEqualTo("calculator");
|
|
// Note: outputSchema might be null in sync server, but validation still works
|
|
|
|
// Call tool with valid structured output
|
|
CallToolResult response = mcpClient
|
|
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
|
|
|
|
assertThat(response).isNotNull();
|
|
assertThat(response.isError()).isTrue();
|
|
assertThat(response.content()).isNotEmpty();
|
|
assertThat(response.content())
|
|
.containsExactly(new McpSchema.TextContent("Error calling tool: Simulated in-handler error"));
|
|
assertThat(response.structuredContent()).isNull();
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@ValueSource(strings = { "httpclient" })
|
|
void testStructuredOutputValidationFailure(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
// Create a tool with output schema
|
|
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
|
|
Map.of("result", Map.of("type", "number"), "operation", Map.of("type", "string")), "required",
|
|
List.of("result", "operation"));
|
|
|
|
Tool calculatorTool = Tool.builder()
|
|
.name("calculator")
|
|
.description("Performs mathematical calculations")
|
|
.outputSchema(outputSchema)
|
|
.build();
|
|
|
|
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(calculatorTool)
|
|
.callHandler((exchange, request) -> {
|
|
// Return invalid structured output. Result should be number, missing
|
|
// operation
|
|
return CallToolResult.builder()
|
|
.addTextContent("Invalid calculation")
|
|
.structuredContent(Map.of("result", "not-a-number", "extra", "field"))
|
|
.build();
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
// Call tool with invalid structured output
|
|
CallToolResult response = mcpClient
|
|
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
|
|
|
|
assertThat(response).isNotNull();
|
|
assertThat(response.isError()).isTrue();
|
|
assertThat(response.content()).hasSize(1);
|
|
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
|
|
|
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
|
|
assertThat(errorMessage).contains("Validation failed");
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testStructuredOutputMissingStructuredContent(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
// Create a tool with output schema
|
|
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
|
|
Map.of("result", Map.of("type", "number")), "required", List.of("result"));
|
|
|
|
Tool calculatorTool = Tool.builder()
|
|
.name("calculator")
|
|
.description("Performs mathematical calculations")
|
|
.outputSchema(outputSchema)
|
|
.build();
|
|
|
|
McpServerFeatures.SyncToolSpecification tool = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(calculatorTool)
|
|
.callHandler((exchange, request) -> {
|
|
// Return result without structured content but tool has output schema
|
|
return CallToolResult.builder().addTextContent("Calculation completed").build();
|
|
})
|
|
.build();
|
|
|
|
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.tools(tool)
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
// Call tool that should return structured content but doesn't
|
|
CallToolResult response = mcpClient
|
|
.callTool(new McpSchema.CallToolRequest("calculator", Map.of("expression", "2 + 3")));
|
|
|
|
assertThat(response).isNotNull();
|
|
assertThat(response.isError()).isTrue();
|
|
assertThat(response.content()).hasSize(1);
|
|
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
|
|
|
String errorMessage = ((McpSchema.TextContent) response.content().get(0)).text();
|
|
assertThat(errorMessage).isEqualTo(
|
|
"Response missing structured content which is expected when calling tool with non-empty outputSchema");
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
@MethodSource("clientsForTesting")
|
|
void testStructuredOutputRuntimeToolAddition(String clientType) {
|
|
|
|
var clientBuilder = clientBuilders.get(clientType);
|
|
|
|
// Start server without tools
|
|
var mcpServer = prepareSyncServerBuilder().serverInfo("test-server", "1.0.0")
|
|
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
.build();
|
|
|
|
try (var mcpClient = clientBuilder.build()) {
|
|
InitializeResult initResult = mcpClient.initialize();
|
|
assertThat(initResult).isNotNull();
|
|
|
|
// Initially no tools
|
|
assertThat(mcpClient.listTools().tools()).isEmpty();
|
|
|
|
// Add tool with output schema at runtime
|
|
Map<String, Object> outputSchema = Map.of("type", "object", "properties",
|
|
Map.of("message", Map.of("type", "string"), "count", Map.of("type", "integer")), "required",
|
|
List.of("message", "count"));
|
|
|
|
Tool dynamicTool = Tool.builder()
|
|
.name("dynamic-tool")
|
|
.description("Dynamically added tool")
|
|
.outputSchema(outputSchema)
|
|
.build();
|
|
|
|
McpServerFeatures.SyncToolSpecification toolSpec = McpServerFeatures.SyncToolSpecification.builder()
|
|
.tool(dynamicTool)
|
|
.callHandler((exchange, request) -> {
|
|
int count = (Integer) request.arguments().getOrDefault("count", 1);
|
|
return CallToolResult.builder()
|
|
.addTextContent("Dynamic tool executed " + count + " times")
|
|
.structuredContent(Map.of("message", "Dynamic execution", "count", count))
|
|
.build();
|
|
})
|
|
.build();
|
|
|
|
// Add tool to server
|
|
mcpServer.addTool(toolSpec);
|
|
|
|
// Wait for tool list change notification
|
|
await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
|
|
assertThat(mcpClient.listTools().tools()).hasSize(1);
|
|
});
|
|
|
|
// Verify tool was added with output schema
|
|
var toolsList = mcpClient.listTools();
|
|
assertThat(toolsList.tools()).hasSize(1);
|
|
assertThat(toolsList.tools().get(0).name()).isEqualTo("dynamic-tool");
|
|
// Note: outputSchema might be null in sync server, but validation still works
|
|
|
|
// Call dynamically added tool
|
|
CallToolResult response = mcpClient
|
|
.callTool(new McpSchema.CallToolRequest("dynamic-tool", Map.of("count", 3)));
|
|
|
|
assertThat(response).isNotNull();
|
|
assertThat(response.isError()).isFalse();
|
|
|
|
assertThat(response.content()).hasSize(1);
|
|
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
|
assertThat(((McpSchema.TextContent) response.content().get(0)).text())
|
|
.isEqualTo("Dynamic tool executed 3 times");
|
|
|
|
assertThat(response.structuredContent()).isNotNull();
|
|
assertThatJson(response.structuredContent()).when(Option.IGNORING_ARRAY_ORDER)
|
|
.when(Option.IGNORING_EXTRA_ARRAY_ITEMS)
|
|
.isObject()
|
|
.isEqualTo(json("""
|
|
{"count":3,"message":"Dynamic execution"}"""));
|
|
}
|
|
finally {
|
|
mcpServer.closeGracefully();
|
|
}
|
|
}
|
|
|
|
private double evaluateExpression(String expression) {
|
|
// Simple expression evaluator for testing
|
|
return switch (expression) {
|
|
case "2 + 3" -> 5.0;
|
|
case "10 * 2" -> 20.0;
|
|
case "7 + 8" -> 15.0;
|
|
case "5 + 3" -> 8.0;
|
|
default -> 0.0;
|
|
};
|
|
}
|
|
|
|
}
|