refactor: extract common integration test logic into abstract base classes (#473)
refactor: extract common integration test logic into abstract base classes - Move duplicated test methods from WebFlux and WebMvc integration test classes to abstract base classes - WebFluxSseIntegrationTests, WebFluxStreamableIntegrationTests now extend AbstractMcpClientServerIntegrationTests - WebFluxStatelessIntegrationTests, WebMvcStatelessIntegrationTests now extend AbstractStatelessIntegrationTests - Each concrete test class now only implements transport-specific setup methods (prepareClients, prepareAsyncServerBuilder, prepareSyncServerBuilder) - Eliminates ~1300+ lines of duplicated test code across multiple files - Improves maintainability by centralizing test logic in reusable base classes - Updates WebMvcSseServerTransportProvider to use builder pattern - Adds new HttpServletSseIntegrationTests extending AbstractMcpClientServerIntegrationTests - Removes HttpServletSseServerTransportProviderIntegrationTests - Standardizes timeout configurations and client setup across all integration tests Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
parent
387998389e
commit
dee8411c8f
11 changed files with 792 additions and 4785 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -4,51 +4,29 @@
|
||||||
|
|
||||||
package io.modelcontextprotocol;
|
package io.modelcontextprotocol;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.springframework.http.server.reactive.HttpHandler;
|
||||||
|
import org.springframework.http.server.reactive.ReactorHttpHandlerAdapter;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import io.modelcontextprotocol.client.McpClient;
|
import io.modelcontextprotocol.client.McpClient;
|
||||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||||
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
|
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
|
||||||
import io.modelcontextprotocol.server.McpServer;
|
import io.modelcontextprotocol.server.McpServer;
|
||||||
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
|
import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification;
|
||||||
|
import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification;
|
||||||
import io.modelcontextprotocol.server.TestUtil;
|
import io.modelcontextprotocol.server.TestUtil;
|
||||||
import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport;
|
import io.modelcontextprotocol.server.transport.WebFluxStatelessServerTransport;
|
||||||
import io.modelcontextprotocol.spec.McpSchema;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema.Prompt;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
|
||||||
import io.modelcontextprotocol.spec.McpSchema.Tool;
|
|
||||||
import io.modelcontextprotocol.server.McpTransportContext;
|
|
||||||
import net.javacrumbs.jsonunit.core.Option;
|
|
||||||
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 org.springframework.http.server.reactive.HttpHandler;
|
|
||||||
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.netty.DisposableServer;
|
import reactor.netty.DisposableServer;
|
||||||
import reactor.netty.http.server.HttpServer;
|
import reactor.netty.http.server.HttpServer;
|
||||||
|
|
||||||
import java.time.Duration;
|
class WebFluxStatelessIntegrationTests extends AbstractStatelessIntegrationTests {
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
import java.util.function.BiFunction;
|
|
||||||
|
|
||||||
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.awaitility.Awaitility.await;
|
|
||||||
|
|
||||||
class WebFluxStatelessIntegrationTests {
|
|
||||||
|
|
||||||
private static final int PORT = TestUtil.findAvailablePort();
|
private static final int PORT = TestUtil.findAvailablePort();
|
||||||
|
|
||||||
|
|
@ -58,19 +36,8 @@ class WebFluxStatelessIntegrationTests {
|
||||||
|
|
||||||
private WebFluxStatelessServerTransport mcpStreamableServerTransport;
|
private WebFluxStatelessServerTransport mcpStreamableServerTransport;
|
||||||
|
|
||||||
ConcurrentHashMap<String, McpClient.SyncSpec> clientBuilders = new ConcurrentHashMap<>();
|
@Override
|
||||||
|
protected void prepareClients(int port, String mcpEndpoint) {
|
||||||
@BeforeEach
|
|
||||||
public void before() {
|
|
||||||
this.mcpStreamableServerTransport = WebFluxStatelessServerTransport.builder()
|
|
||||||
.objectMapper(new ObjectMapper())
|
|
||||||
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpStreamableServerTransport.getRouterFunction());
|
|
||||||
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
|
||||||
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
|
||||||
|
|
||||||
clientBuilders
|
clientBuilders
|
||||||
.put("httpclient",
|
.put("httpclient",
|
||||||
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
||||||
|
|
@ -83,7 +50,30 @@ class WebFluxStatelessIntegrationTests {
|
||||||
.build())
|
.build())
|
||||||
.initializationTimeout(Duration.ofHours(10))
|
.initializationTimeout(Duration.ofHours(10))
|
||||||
.requestTimeout(Duration.ofHours(10)));
|
.requestTimeout(Duration.ofHours(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StatelessAsyncSpecification prepareAsyncServerBuilder() {
|
||||||
|
return McpServer.async(this.mcpStreamableServerTransport);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StatelessSyncSpecification prepareSyncServerBuilder() {
|
||||||
|
return McpServer.sync(this.mcpStreamableServerTransport);
|
||||||
|
}
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void before() {
|
||||||
|
this.mcpStreamableServerTransport = WebFluxStatelessServerTransport.builder()
|
||||||
|
.objectMapper(new ObjectMapper())
|
||||||
|
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpHandler httpHandler = RouterFunctions.toHttpHandler(mcpStreamableServerTransport.getRouterFunction());
|
||||||
|
ReactorHttpHandlerAdapter adapter = new ReactorHttpHandlerAdapter(httpHandler);
|
||||||
|
this.httpServer = HttpServer.create().port(PORT).handle(adapter).bindNow();
|
||||||
|
|
||||||
|
prepareClients(PORT, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
|
|
@ -93,381 +83,4 @@ class WebFluxStatelessIntegrationTests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------
|
|
||||||
// Tools Tests
|
|
||||||
// ---------------------------------------
|
|
||||||
|
|
||||||
String emptyJsonSchema = """
|
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
|
||||||
void testToolCallSuccess(String clientType) {
|
|
||||||
|
|
||||||
var clientBuilder = clientBuilders.get(clientType);
|
|
||||||
|
|
||||||
var callResponse = new CallToolResult(List.of(new McpSchema.TextContent("CALL RESPONSE")), null);
|
|
||||||
McpStatelessServerFeatures.SyncToolSpecification tool1 = new McpStatelessServerFeatures.SyncToolSpecification(
|
|
||||||
new Tool("tool1", "tool1 description", emptyJsonSchema), (transportContext, request) -> {
|
|
||||||
// perform a blocking call to a remote service
|
|
||||||
String response = RestClient.create()
|
|
||||||
.get()
|
|
||||||
.uri("https://raw.githubusercontent.com/modelcontextprotocol/java-sdk/refs/heads/main/README.md")
|
|
||||||
.retrieve()
|
|
||||||
.body(String.class);
|
|
||||||
assertThat(response).isNotBlank();
|
|
||||||
return callResponse;
|
|
||||||
});
|
|
||||||
|
|
||||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
|
||||||
.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(response).isNotNull();
|
|
||||||
assertThat(response).isEqualTo(callResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpServer.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
|
||||||
void testInitialize(String clientType) {
|
|
||||||
|
|
||||||
var clientBuilder = clientBuilders.get(clientType);
|
|
||||||
|
|
||||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport).build();
|
|
||||||
|
|
||||||
try (var mcpClient = clientBuilder.build()) {
|
|
||||||
InitializeResult initResult = mcpClient.initialize();
|
|
||||||
assertThat(initResult).isNotNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpServer.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------
|
|
||||||
// Completion Tests
|
|
||||||
// ---------------------------------------
|
|
||||||
@ParameterizedTest(name = "{0} : Completion call")
|
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
|
||||||
void testCompletionShouldReturnExpectedSuggestions(String clientType) {
|
|
||||||
var clientBuilder = clientBuilders.get(clientType);
|
|
||||||
|
|
||||||
var expectedValues = List.of("python", "pytorch", "pyside");
|
|
||||||
var completionResponse = new CompleteResult(new CompleteResult.CompleteCompletion(expectedValues, 10, // total
|
|
||||||
true // hasMore
|
|
||||||
));
|
|
||||||
|
|
||||||
AtomicReference<CompleteRequest> samplingRequest = new AtomicReference<>();
|
|
||||||
BiFunction<McpTransportContext, CompleteRequest, CompleteResult> completionHandler = (transportContext,
|
|
||||||
request) -> {
|
|
||||||
samplingRequest.set(request);
|
|
||||||
return completionResponse;
|
|
||||||
};
|
|
||||||
|
|
||||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
|
||||||
.capabilities(ServerCapabilities.builder().completions().build())
|
|
||||||
.prompts(new McpStatelessServerFeatures.SyncPromptSpecification(
|
|
||||||
new Prompt("code_review", "Code review", "this is code review prompt",
|
|
||||||
List.of(new PromptArgument("language", "Language", "string", false))),
|
|
||||||
(transportContext, getPromptRequest) -> null))
|
|
||||||
.completions(new McpStatelessServerFeatures.SyncCompletionSpecification(
|
|
||||||
new PromptReference("ref/prompt", "code_review", "Code review"), completionHandler))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
try (var mcpClient = clientBuilder.build()) {
|
|
||||||
|
|
||||||
InitializeResult initResult = mcpClient.initialize();
|
|
||||||
assertThat(initResult).isNotNull();
|
|
||||||
|
|
||||||
CompleteRequest request = new CompleteRequest(
|
|
||||||
new PromptReference("ref/prompt", "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("ref/prompt");
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpServer.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------
|
|
||||||
// Tool Structured Output Schema Tests
|
|
||||||
// ---------------------------------------
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
|
||||||
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();
|
|
||||||
|
|
||||||
McpStatelessServerFeatures.SyncToolSpecification tool = new McpStatelessServerFeatures.SyncToolSpecification(
|
|
||||||
calculatorTool, (transportContext, 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();
|
|
||||||
});
|
|
||||||
|
|
||||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
|
||||||
.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();
|
|
||||||
assertThat(response.content()).hasSize(1);
|
|
||||||
assertThat(response.content().get(0)).isInstanceOf(McpSchema.TextContent.class);
|
|
||||||
|
|
||||||
assertThatJson(((McpSchema.TextContent) response.content().get(0)).text()).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"}"""));
|
|
||||||
|
|
||||||
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"}"""));
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpServer.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
|
||||||
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();
|
|
||||||
|
|
||||||
McpStatelessServerFeatures.SyncToolSpecification tool = new McpStatelessServerFeatures.SyncToolSpecification(
|
|
||||||
calculatorTool, (transportContext, 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();
|
|
||||||
});
|
|
||||||
|
|
||||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
|
||||||
.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");
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpServer.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
|
||||||
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();
|
|
||||||
|
|
||||||
McpStatelessServerFeatures.SyncToolSpecification tool = new McpStatelessServerFeatures.SyncToolSpecification(
|
|
||||||
calculatorTool, (transportContext, request) -> {
|
|
||||||
// Return result without structured content but tool has output schema
|
|
||||||
return CallToolResult.builder().addTextContent("Calculation completed").build();
|
|
||||||
});
|
|
||||||
|
|
||||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
|
||||||
.serverInfo("test-server", "1.0.0")
|
|
||||||
.capabilities(ServerCapabilities.builder().tools(true).build())
|
|
||||||
.instructions("bla")
|
|
||||||
.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");
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpServer.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
|
||||||
void testStructuredOutputRuntimeToolAddition(String clientType) {
|
|
||||||
var clientBuilder = clientBuilders.get(clientType);
|
|
||||||
|
|
||||||
// Start server without tools
|
|
||||||
var mcpServer = McpServer.sync(mcpStreamableServerTransport)
|
|
||||||
.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();
|
|
||||||
|
|
||||||
McpStatelessServerFeatures.SyncToolSpecification toolSpec = new McpStatelessServerFeatures.SyncToolSpecification(
|
|
||||||
dynamicTool, (transportContext, 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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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"}"""));
|
|
||||||
}
|
|
||||||
|
|
||||||
mcpServer.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -42,11 +42,11 @@ class WebMvcSseIntegrationTests extends AbstractMcpClientServerIntegrationTests
|
||||||
|
|
||||||
clientBuilders.put("httpclient",
|
clientBuilders.put("httpclient",
|
||||||
McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + port).build())
|
McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + port).build())
|
||||||
.initializationTimeout(Duration.ofHours(10))
|
|
||||||
.requestTimeout(Duration.ofHours(10)));
|
.requestTimeout(Duration.ofHours(10)));
|
||||||
|
|
||||||
clientBuilders.put("webflux", McpClient
|
clientBuilders.put("webflux", McpClient
|
||||||
.sync(WebFluxSseClientTransport.builder(WebClient.builder().baseUrl("http://localhost:" + port)).build()));
|
.sync(WebFluxSseClientTransport.builder(WebClient.builder().baseUrl("http://localhost:" + port)).build())
|
||||||
|
.requestTimeout(Duration.ofHours(10)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
|
@ -55,7 +55,10 @@ class WebMvcSseIntegrationTests extends AbstractMcpClientServerIntegrationTests
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() {
|
public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() {
|
||||||
return new WebMvcSseServerTransportProvider(new ObjectMapper(), MESSAGE_ENDPOINT);
|
return WebMvcSseServerTransportProvider.builder()
|
||||||
|
.objectMapper(new ObjectMapper())
|
||||||
|
.messageEndpoint(MESSAGE_ENDPOINT)
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import org.apache.catalina.LifecycleException;
|
||||||
import org.apache.catalina.LifecycleState;
|
import org.apache.catalina.LifecycleState;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
@ -29,7 +27,6 @@ import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport
|
||||||
import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification;
|
import io.modelcontextprotocol.server.McpServer.StatelessAsyncSpecification;
|
||||||
import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification;
|
import io.modelcontextprotocol.server.McpServer.StatelessSyncSpecification;
|
||||||
import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport;
|
import io.modelcontextprotocol.server.transport.WebMvcStatelessServerTransport;
|
||||||
import io.modelcontextprotocol.spec.McpSchema;
|
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
class WebMvcStatelessIntegrationTests extends AbstractStatelessIntegrationTests {
|
class WebMvcStatelessIntegrationTests extends AbstractStatelessIntegrationTests {
|
||||||
|
|
@ -63,6 +60,32 @@ class WebMvcStatelessIntegrationTests extends AbstractStatelessIntegrationTests
|
||||||
|
|
||||||
private TomcatTestUtil.TomcatServer tomcatServer;
|
private TomcatTestUtil.TomcatServer tomcatServer;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StatelessAsyncSpecification prepareAsyncServerBuilder() {
|
||||||
|
return McpServer.async(this.mcpServerTransport);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected StatelessSyncSpecification prepareSyncServerBuilder() {
|
||||||
|
return McpServer.sync(this.mcpServerTransport);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void prepareClients(int port, String mcpEndpoint) {
|
||||||
|
|
||||||
|
clientBuilders.put("httpclient", McpClient
|
||||||
|
.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint(mcpEndpoint).build())
|
||||||
|
.requestTimeout(Duration.ofHours(10)));
|
||||||
|
|
||||||
|
clientBuilders.put("webflux",
|
||||||
|
McpClient
|
||||||
|
.sync(WebClientStreamableHttpTransport
|
||||||
|
.builder(WebClient.builder().baseUrl("http://localhost:" + port))
|
||||||
|
.endpoint(mcpEndpoint)
|
||||||
|
.build())
|
||||||
|
.requestTimeout(Duration.ofHours(10)));
|
||||||
|
}
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
public void before() {
|
public void before() {
|
||||||
|
|
||||||
|
|
@ -76,33 +99,13 @@ class WebMvcStatelessIntegrationTests extends AbstractStatelessIntegrationTests
|
||||||
throw new RuntimeException("Failed to start Tomcat", e);
|
throw new RuntimeException("Failed to start Tomcat", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
clientBuilders
|
prepareClients(PORT, MESSAGE_ENDPOINT);
|
||||||
.put("httpclient",
|
|
||||||
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
|
||||||
.endpoint(MESSAGE_ENDPOINT)
|
|
||||||
.build()).initializationTimeout(Duration.ofHours(10)).requestTimeout(Duration.ofHours(10)));
|
|
||||||
|
|
||||||
clientBuilders.put("webflux",
|
|
||||||
McpClient.sync(WebClientStreamableHttpTransport
|
|
||||||
.builder(WebClient.builder().baseUrl("http://localhost:" + PORT))
|
|
||||||
.endpoint(MESSAGE_ENDPOINT)
|
|
||||||
.build()));
|
|
||||||
|
|
||||||
// Get the transport from Spring context
|
// Get the transport from Spring context
|
||||||
this.mcpServerTransport = tomcatServer.appContext().getBean(WebMvcStatelessServerTransport.class);
|
this.mcpServerTransport = tomcatServer.appContext().getBean(WebMvcStatelessServerTransport.class);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
protected StatelessAsyncSpecification prepareAsyncServerBuilder() {
|
|
||||||
return McpServer.async(this.mcpServerTransport);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected StatelessSyncSpecification prepareSyncServerBuilder() {
|
|
||||||
return McpServer.sync(this.mcpServerTransport);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
@AfterEach
|
||||||
public void after() {
|
public void after() {
|
||||||
reactor.netty.http.HttpResources.disposeLoopsAndConnections();
|
reactor.netty.http.HttpResources.disposeLoopsAndConnections();
|
||||||
|
|
@ -124,42 +127,4 @@ class WebMvcStatelessIntegrationTests extends AbstractStatelessIntegrationTests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
|
||||||
void simple(String clientType) {
|
|
||||||
|
|
||||||
var clientBuilder = clientBuilders.get(clientType);
|
|
||||||
|
|
||||||
var server = McpServer.async(this.mcpServerTransport)
|
|
||||||
.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();
|
|
||||||
|
|
||||||
}
|
|
||||||
server.closeGracefully();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void prepareClients(int port, String mcpEndpoint) {
|
|
||||||
|
|
||||||
clientBuilders.put("httpclient", McpClient
|
|
||||||
.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint(mcpEndpoint).build())
|
|
||||||
.initializationTimeout(Duration.ofHours(10))
|
|
||||||
.requestTimeout(Duration.ofHours(10)));
|
|
||||||
|
|
||||||
clientBuilders.put("webflux",
|
|
||||||
McpClient.sync(WebClientStreamableHttpTransport
|
|
||||||
.builder(WebClient.builder().baseUrl("http://localhost:" + port))
|
|
||||||
.endpoint(mcpEndpoint)
|
|
||||||
.build()));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,42 +124,20 @@ class WebMvcStreamableIntegrationTests extends AbstractMcpClientServerIntegratio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
|
||||||
void simple(String clientType) {
|
|
||||||
|
|
||||||
var clientBuilder = clientBuilders.get(clientType);
|
|
||||||
|
|
||||||
var server = McpServer.async(mcpServerTransportProvider)
|
|
||||||
.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();
|
|
||||||
|
|
||||||
}
|
|
||||||
server.closeGracefully();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void prepareClients(int port, String mcpEndpoint) {
|
protected void prepareClients(int port, String mcpEndpoint) {
|
||||||
|
|
||||||
clientBuilders.put("httpclient", McpClient
|
clientBuilders.put("httpclient", McpClient
|
||||||
.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint(mcpEndpoint).build())
|
.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).endpoint(mcpEndpoint).build())
|
||||||
.initializationTimeout(Duration.ofHours(10))
|
|
||||||
.requestTimeout(Duration.ofHours(10)));
|
.requestTimeout(Duration.ofHours(10)));
|
||||||
|
|
||||||
clientBuilders.put("webflux",
|
clientBuilders.put("webflux",
|
||||||
McpClient.sync(WebClientStreamableHttpTransport
|
McpClient
|
||||||
.builder(WebClient.builder().baseUrl("http://localhost:" + port))
|
.sync(WebClientStreamableHttpTransport
|
||||||
.endpoint(mcpEndpoint)
|
.builder(WebClient.builder().baseUrl("http://localhost:" + port))
|
||||||
.build()));
|
.endpoint(mcpEndpoint)
|
||||||
|
.build())
|
||||||
|
.requestTimeout(Duration.ofHours(10)));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,13 @@ import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
@ -31,16 +34,22 @@ import io.modelcontextprotocol.client.McpClient;
|
||||||
import io.modelcontextprotocol.server.McpServer;
|
import io.modelcontextprotocol.server.McpServer;
|
||||||
import io.modelcontextprotocol.server.McpServerFeatures;
|
import io.modelcontextprotocol.server.McpServerFeatures;
|
||||||
import io.modelcontextprotocol.server.McpSyncServer;
|
import io.modelcontextprotocol.server.McpSyncServer;
|
||||||
|
import io.modelcontextprotocol.server.McpSyncServerExchange;
|
||||||
import io.modelcontextprotocol.spec.McpError;
|
import io.modelcontextprotocol.spec.McpError;
|
||||||
import io.modelcontextprotocol.spec.McpSchema;
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
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.CreateMessageRequest;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
|
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
|
import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
|
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.ModelPreferences;
|
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.Role;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.Root;
|
import io.modelcontextprotocol.spec.McpSchema.Root;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
||||||
|
|
@ -740,7 +749,6 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
// Tools Tests
|
// Tools Tests
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
|
|
||||||
String emptyJsonSchema = """
|
String emptyJsonSchema = """
|
||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
|
@ -944,6 +952,276 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
||||||
mcpServer.close();
|
mcpServer.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Logging Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||||
|
@ValueSource(strings = { "httpclient", "webflux" })
|
||||||
|
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(emptyJsonSchema)
|
||||||
|
.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(new CallToolResult("Logging test completed", false));
|
||||||
|
//@formatter:on
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().logging().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");
|
||||||
|
}
|
||||||
|
mcpServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Progress Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||||
|
@ValueSource(strings = { "httpclient", "webflux" })
|
||||||
|
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(emptyJsonSchema)
|
||||||
|
.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(new CallToolResult(("Progress test completed"), false));
|
||||||
|
})
|
||||||
|
.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.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Completion Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
@ParameterizedTest(name = "{0} : Completion call")
|
||||||
|
@ValueSource(strings = { "httpclient", "webflux" })
|
||||||
|
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("ref/prompt", "code_review", "Code review"), completionHandler))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (var mcpClient = clientBuilder.build()) {
|
||||||
|
|
||||||
|
InitializeResult initResult = mcpClient.initialize();
|
||||||
|
assertThat(initResult).isNotNull();
|
||||||
|
|
||||||
|
CompleteRequest request = new CompleteRequest(
|
||||||
|
new PromptReference("ref/prompt", "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("ref/prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Ping Tests
|
||||||
|
// ---------------------------------------
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
@ValueSource(strings = { "httpclient", "webflux" })
|
||||||
void testPingSuccess(String clientType) {
|
void testPingSuccess(String clientType) {
|
||||||
|
|
@ -1006,7 +1284,6 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
// Tool Structured Output Schema Tests
|
// Tool Structured Output Schema Tests
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||||
@ValueSource(strings = { "httpclient", "webflux" })
|
@ValueSource(strings = { "httpclient", "webflux" })
|
||||||
void testStructuredOutputValidationSuccess(String clientType) {
|
void testStructuredOutputValidationSuccess(String clientType) {
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,13 @@ import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.concurrent.CountDownLatch;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
import java.util.function.BiFunction;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.ValueSource;
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
|
@ -32,6 +35,8 @@ import io.modelcontextprotocol.spec.McpError;
|
||||||
import io.modelcontextprotocol.spec.McpSchema;
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
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.CreateMessageRequest;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
import io.modelcontextprotocol.spec.McpSchema.CreateMessageResult;
|
||||||
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
|
import io.modelcontextprotocol.spec.McpSchema.ElicitRequest;
|
||||||
|
|
@ -737,7 +742,6 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
// Tools Tests
|
// Tools Tests
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
|
|
||||||
String emptyJsonSchema = """
|
String emptyJsonSchema = """
|
||||||
{
|
{
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
|
@ -941,6 +945,276 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
||||||
mcpServer.close();
|
mcpServer.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Logging Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||||
|
@ValueSource(strings = { "httpclient" })
|
||||||
|
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(emptyJsonSchema)
|
||||||
|
.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(new CallToolResult("Logging test completed", false));
|
||||||
|
//@formatter:on
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
|
||||||
|
var mcpServer = prepareAsyncServerBuilder().serverInfo("test-server", "1.0.0")
|
||||||
|
.capabilities(ServerCapabilities.builder().logging().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");
|
||||||
|
}
|
||||||
|
mcpServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Progress Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||||
|
@ValueSource(strings = { "httpclient" })
|
||||||
|
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(emptyJsonSchema)
|
||||||
|
.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(new CallToolResult(("Progress test completed"), false));
|
||||||
|
})
|
||||||
|
.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.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Completion Tests
|
||||||
|
// ---------------------------------------
|
||||||
|
@ParameterizedTest(name = "{0} : Completion call")
|
||||||
|
@ValueSource(strings = { "httpclient" })
|
||||||
|
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 McpSchema.Prompt("code_review", "Code review", "this is code review prompt",
|
||||||
|
List.of(new McpSchema.PromptArgument("language", "Language", "string", false))),
|
||||||
|
(mcpSyncServerExchange, getPromptRequest) -> null))
|
||||||
|
.completions(new McpServerFeatures.SyncCompletionSpecification(
|
||||||
|
new McpSchema.PromptReference("ref/prompt", "code_review", "Code review"), completionHandler))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try (var mcpClient = clientBuilder.build()) {
|
||||||
|
|
||||||
|
InitializeResult initResult = mcpClient.initialize();
|
||||||
|
assertThat(initResult).isNotNull();
|
||||||
|
|
||||||
|
CompleteRequest request = new CompleteRequest(
|
||||||
|
new McpSchema.PromptReference("ref/prompt", "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("ref/prompt");
|
||||||
|
}
|
||||||
|
|
||||||
|
mcpServer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// Ping Tests
|
||||||
|
// ---------------------------------------
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||||
@ValueSource(strings = { "httpclient" })
|
@ValueSource(strings = { "httpclient" })
|
||||||
void testPingSuccess(String clientType) {
|
void testPingSuccess(String clientType) {
|
||||||
|
|
@ -1003,7 +1277,6 @@ public abstract class AbstractMcpClientServerIntegrationTests {
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
// Tool Structured Output Schema Tests
|
// Tool Structured Output Schema Tests
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} : {displayName} ")
|
@ParameterizedTest(name = "{0} : {displayName} ")
|
||||||
@ValueSource(strings = { "httpclient" })
|
@ValueSource(strings = { "httpclient" })
|
||||||
void testStructuredOutputValidationSuccess(String clientType) {
|
void testStructuredOutputValidationSuccess(String clientType) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 - 2024 the original author or authors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.modelcontextprotocol.server;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import org.apache.catalina.LifecycleException;
|
||||||
|
import org.apache.catalina.LifecycleState;
|
||||||
|
import org.apache.catalina.startup.Tomcat;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import io.modelcontextprotocol.client.McpClient;
|
||||||
|
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
|
||||||
|
import io.modelcontextprotocol.server.McpServer.AsyncSpecification;
|
||||||
|
import io.modelcontextprotocol.server.McpServer.SyncSpecification;
|
||||||
|
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
|
||||||
|
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
|
||||||
|
|
||||||
|
class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationTests {
|
||||||
|
|
||||||
|
private static final int PORT = TomcatTestUtil.findAvailablePort();
|
||||||
|
|
||||||
|
private static final String CUSTOM_SSE_ENDPOINT = "/somePath/sse";
|
||||||
|
|
||||||
|
private static final String CUSTOM_MESSAGE_ENDPOINT = "/otherPath/mcp/message";
|
||||||
|
|
||||||
|
private HttpServletSseServerTransportProvider mcpServerTransportProvider;
|
||||||
|
|
||||||
|
private Tomcat tomcat;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void before() {
|
||||||
|
// Create and configure the transport provider
|
||||||
|
mcpServerTransportProvider = HttpServletSseServerTransportProvider.builder()
|
||||||
|
.objectMapper(new ObjectMapper())
|
||||||
|
.messageEndpoint(CUSTOM_MESSAGE_ENDPOINT)
|
||||||
|
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
tomcat = TomcatTestUtil.createTomcatServer("", PORT, mcpServerTransportProvider);
|
||||||
|
try {
|
||||||
|
tomcat.start();
|
||||||
|
assertThat(tomcat.getServer().getState()).isEqualTo(LifecycleState.STARTED);
|
||||||
|
}
|
||||||
|
catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to start Tomcat", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
clientBuilders
|
||||||
|
.put("httpclient",
|
||||||
|
McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + PORT)
|
||||||
|
.sseEndpoint(CUSTOM_SSE_ENDPOINT)
|
||||||
|
.build()).requestTimeout(Duration.ofHours(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected AsyncSpecification<?> prepareAsyncServerBuilder() {
|
||||||
|
return McpServer.async(this.mcpServerTransportProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected SyncSpecification<?> prepareSyncServerBuilder() {
|
||||||
|
return McpServer.sync(this.mcpServerTransportProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
public void after() {
|
||||||
|
if (mcpServerTransportProvider != null) {
|
||||||
|
mcpServerTransportProvider.closeGracefully().block();
|
||||||
|
}
|
||||||
|
if (tomcat != null) {
|
||||||
|
try {
|
||||||
|
tomcat.stop();
|
||||||
|
tomcat.destroy();
|
||||||
|
}
|
||||||
|
catch (LifecycleException e) {
|
||||||
|
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void prepareClients(int port, String mcpEndpoint) {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerInteg
|
||||||
.put("httpclient",
|
.put("httpclient",
|
||||||
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
||||||
.endpoint(MESSAGE_ENDPOINT)
|
.endpoint(MESSAGE_ENDPOINT)
|
||||||
.build()).initializationTimeout(Duration.ofHours(10)).requestTimeout(Duration.ofHours(10)));
|
.build()).requestTimeout(Duration.ofHours(10)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue