Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2464ad22c5 | ||
|
|
ea50838d06 | ||
|
|
ed2c35ea02 | ||
|
|
dee8411c8f | ||
|
|
387998389e | ||
|
|
31aaea538f | ||
|
|
d756be1629 | ||
|
|
c49f24f068 | ||
|
|
f7baf59acf | ||
|
|
5365bf4805 | ||
|
|
cc1b1c9936 | ||
|
|
c732040436 | ||
|
|
9e959b72cb | ||
|
|
69cf41b12f | ||
|
|
9949657ce3 | ||
|
|
5681f1ea4c | ||
|
|
a49b9f5914 | ||
|
|
1324730d95 | ||
|
|
ff4630d932 |
107 changed files with 2420 additions and 5164 deletions
|
|
@ -7,7 +7,7 @@
|
|||
<parent>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-parent</artifactId>
|
||||
<version>0.11.0-SNAPSHOT</version>
|
||||
<version>0.11.2</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>mcp-bom</artifactId>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<parent>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-parent</artifactId>
|
||||
<version>0.11.0-SNAPSHOT</version>
|
||||
<version>0.11.2</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>mcp-spring-webflux</artifactId>
|
||||
|
|
@ -25,13 +25,13 @@
|
|||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
<version>0.11.0-SNAPSHOT</version>
|
||||
<version>0.11.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-test</artifactId>
|
||||
<version>0.11.0-SNAPSHOT</version>
|
||||
<version>0.11.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
|
|
@ -27,9 +31,11 @@ import io.modelcontextprotocol.spec.HttpHeaders;
|
|||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import io.modelcontextprotocol.spec.McpTransportSession;
|
||||
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
|
||||
import io.modelcontextprotocol.spec.McpTransportStream;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import reactor.core.Disposable;
|
||||
|
|
@ -65,9 +71,11 @@ import reactor.util.function.Tuples;
|
|||
*/
|
||||
public class WebClientStreamableHttpTransport implements McpClientTransport {
|
||||
|
||||
private static final String MISSING_SESSION_ID = "[missing_session_id]";
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebClientStreamableHttpTransport.class);
|
||||
|
||||
private static final String MCP_PROTOCOL_VERSION = "2025-03-26";
|
||||
private static final String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2025_03_26;
|
||||
|
||||
private static final String DEFAULT_ENDPOINT = "/mcp";
|
||||
|
||||
|
|
@ -107,8 +115,8 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
}
|
||||
|
||||
@Override
|
||||
public String protocolVersion() {
|
||||
return MCP_PROTOCOL_VERSION;
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -138,11 +146,8 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
Function<String, Publisher<Void>> onClose = sessionId -> sessionId == null ? Mono.empty()
|
||||
: webClient.delete()
|
||||
.uri(this.endpoint)
|
||||
.header(HttpHeaders.MCP_SESSION_ID, sessionId)
|
||||
.header(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION)
|
||||
.headers(httpHeaders -> {
|
||||
httpHeaders.add(HttpHeaders.MCP_SESSION_ID, sessionId);
|
||||
httpHeaders.add(HttpHeaders.PROTOCOL_VERSION, MCP_PROTOCOL_VERSION);
|
||||
})
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.onErrorComplete(e -> {
|
||||
|
|
@ -219,8 +224,13 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
return Flux.empty();
|
||||
}
|
||||
else if (isNotFound(response)) {
|
||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
return mcpSessionNotFoundError(sessionIdRepresentation);
|
||||
if (transportSession.sessionId().isPresent()) {
|
||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
return mcpSessionNotFoundError(sessionIdRepresentation);
|
||||
}
|
||||
else {
|
||||
return this.extractError(response, MISSING_SESSION_ID);
|
||||
}
|
||||
}
|
||||
else {
|
||||
return response.<McpSchema.JSONRPCMessage>createError().doOnError(e -> {
|
||||
|
|
@ -316,10 +326,10 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
}
|
||||
}
|
||||
else {
|
||||
if (isNotFound(response)) {
|
||||
if (isNotFound(response) && !sessionRepresentation.equals(MISSING_SESSION_ID)) {
|
||||
return mcpSessionNotFoundError(sessionRepresentation);
|
||||
}
|
||||
return extractError(response, sessionRepresentation);
|
||||
return this.extractError(response, sessionRepresentation);
|
||||
}
|
||||
})
|
||||
.flatMap(jsonRpcMessage -> this.handler.get().apply(Mono.just(jsonRpcMessage)))
|
||||
|
|
@ -360,10 +370,10 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
McpSchema.JSONRPCResponse.class);
|
||||
jsonRpcError = jsonRpcResponse.error();
|
||||
toPropagate = jsonRpcError != null ? new McpError(jsonRpcError)
|
||||
: new McpError("Can't parse the jsonResponse " + jsonRpcResponse);
|
||||
: new McpTransportException("Can't parse the jsonResponse " + jsonRpcResponse);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
toPropagate = new RuntimeException("Sending request failed", e);
|
||||
toPropagate = new McpTransportException("Sending request failed, " + e.getMessage(), e);
|
||||
logger.debug("Received content together with {} HTTP code response: {}", response.statusCode(), body);
|
||||
}
|
||||
|
||||
|
|
@ -372,7 +382,11 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
// invalidate the session
|
||||
// https://github.com/modelcontextprotocol/typescript-sdk/issues/389
|
||||
if (responseException.getStatusCode().isSameCodeAs(HttpStatus.BAD_REQUEST)) {
|
||||
return Mono.error(new McpTransportSessionNotFoundException(sessionRepresentation, toPropagate));
|
||||
if (!sessionRepresentation.equals(MISSING_SESSION_ID)) {
|
||||
return Mono.error(new McpTransportSessionNotFoundException(sessionRepresentation, toPropagate));
|
||||
}
|
||||
return Mono.error(new McpTransportException("Received 400 BAD REQUEST for session "
|
||||
+ sessionRepresentation + ". " + toPropagate.getMessage(), toPropagate));
|
||||
}
|
||||
return Mono.error(toPropagate);
|
||||
}).flux();
|
||||
|
|
@ -401,7 +415,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
}
|
||||
|
||||
private static String sessionIdOrPlaceholder(McpTransportSession<?> transportSession) {
|
||||
return transportSession.sessionId().orElse("[missing_session_id]");
|
||||
return transportSession.sessionId().orElse(MISSING_SESSION_ID);
|
||||
}
|
||||
|
||||
private Flux<McpSchema.JSONRPCMessage> directResponseFlux(McpSchema.JSONRPCMessage sentMessage,
|
||||
|
|
@ -419,8 +433,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
// TODO: this should be a McpTransportError
|
||||
s.error(e);
|
||||
s.error(new McpTransportException(e));
|
||||
}
|
||||
}).flatMapIterable(Function.identity());
|
||||
}
|
||||
|
|
@ -447,7 +460,7 @@ public class WebClientStreamableHttpTransport implements McpClientTransport {
|
|||
return Tuples.of(Optional.ofNullable(event.id()), List.of(message));
|
||||
}
|
||||
catch (IOException ioException) {
|
||||
throw new McpError("Error parsing JSON-RPC message: " + event.data());
|
||||
throw new McpTransportException("Error parsing JSON-RPC message: " + event.data(), ioException);
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
|
|
@ -12,9 +14,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
|
||||
import io.modelcontextprotocol.spec.HttpHeaders;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
|
@ -64,7 +66,7 @@ public class WebFluxSseClientTransport implements McpClientTransport {
|
|||
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebFluxSseClientTransport.class);
|
||||
|
||||
private static final String MCP_PROTOCOL_VERSION = "2024-11-05";
|
||||
private static final String MCP_PROTOCOL_VERSION = ProtocolVersions.MCP_2024_11_05;
|
||||
|
||||
/**
|
||||
* Event type for JSON-RPC messages received through the SSE connection. The server
|
||||
|
|
@ -171,8 +173,8 @@ public class WebFluxSseClientTransport implements McpClientTransport {
|
|||
}
|
||||
|
||||
@Override
|
||||
public String protocolVersion() {
|
||||
return MCP_PROTOCOL_VERSION;
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(MCP_PROTOCOL_VERSION);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -194,8 +196,6 @@ public class WebFluxSseClientTransport implements McpClientTransport {
|
|||
* @param handler a function that processes incoming JSON-RPC messages and returns
|
||||
* responses
|
||||
* @return a Mono that completes when the connection is fully established
|
||||
* @throws McpError if there's an error processing SSE events or if an unrecognized
|
||||
* event type is received
|
||||
*/
|
||||
@Override
|
||||
public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> handler) {
|
||||
|
|
@ -212,7 +212,7 @@ public class WebFluxSseClientTransport implements McpClientTransport {
|
|||
else {
|
||||
// TODO: clarify with the spec if multiple events can be
|
||||
// received
|
||||
s.error(new McpError("Failed to handle SSE endpoint event"));
|
||||
s.error(new RuntimeException("Failed to handle SSE endpoint event"));
|
||||
}
|
||||
}
|
||||
else if (MESSAGE_EVENT_TYPE.equals(event.event())) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
|
@ -11,6 +16,7 @@ import io.modelcontextprotocol.spec.McpSchema;
|
|||
import io.modelcontextprotocol.spec.McpServerSession;
|
||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.KeepAliveScheduler;
|
||||
|
||||
|
|
@ -215,8 +221,8 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
|
|||
}
|
||||
|
||||
@Override
|
||||
public String protocolVersion() {
|
||||
return "2024-11-05";
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(ProtocolVersions.MCP_2024_11_05);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
|
@ -10,6 +14,7 @@ import io.modelcontextprotocol.spec.McpSchema;
|
|||
import io.modelcontextprotocol.spec.McpStreamableServerSession;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.server.McpTransportContext;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.KeepAliveScheduler;
|
||||
|
|
@ -89,15 +94,11 @@ public class WebFluxStreamableServerTransportProvider implements McpStreamableSe
|
|||
|
||||
this.keepAliveScheduler.start();
|
||||
}
|
||||
else {
|
||||
logger.warn("Keep-alive interval is not set or invalid. No keep-alive will be scheduled.");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public String protocolVersion() {
|
||||
return "2025-03-26";
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(ProtocolVersions.MCP_2024_11_05, ProtocolVersions.MCP_2025_03_26);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,53 +1,34 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
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 io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
|
||||
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
|
||||
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.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.http.server.HttpServer;
|
||||
|
||||
import java.time.Duration;
|
||||
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 {
|
||||
@Timeout(15)
|
||||
class WebFluxStatelessIntegrationTests extends AbstractStatelessIntegrationTests {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
|
|
@ -57,19 +38,8 @@ class WebFluxStatelessIntegrationTests {
|
|||
|
||||
private WebFluxStatelessServerTransport mcpStreamableServerTransport;
|
||||
|
||||
ConcurrentHashMap<String, McpClient.SyncSpec> clientBuilders = new ConcurrentHashMap<>();
|
||||
|
||||
@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();
|
||||
|
||||
@Override
|
||||
protected void prepareClients(int port, String mcpEndpoint) {
|
||||
clientBuilders
|
||||
.put("httpclient",
|
||||
McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + PORT)
|
||||
|
|
@ -82,7 +52,30 @@ class WebFluxStatelessIntegrationTests {
|
|||
.build())
|
||||
.initializationTimeout(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
|
||||
|
|
@ -92,381 +85,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
|
|
@ -1,3 +1,7 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import io.modelcontextprotocol.client.transport.WebClientStreamableHttpTransport;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client;
|
||||
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,404 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import io.modelcontextprotocol.server.TestUtil;
|
||||
import io.modelcontextprotocol.spec.HttpHeaders;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
/**
|
||||
* Tests for error handling in WebClientStreamableHttpTransport. Addresses concurrency
|
||||
* issues with proper Reactor patterns.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
@Timeout(15)
|
||||
public class WebClientStreamableHttpTransportErrorHandlingTest {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
private static final String HOST = "http://localhost:" + PORT;
|
||||
|
||||
private HttpServer server;
|
||||
|
||||
private AtomicReference<Integer> serverResponseStatus = new AtomicReference<>(200);
|
||||
|
||||
private AtomicReference<String> currentServerSessionId = new AtomicReference<>(null);
|
||||
|
||||
private AtomicReference<String> lastReceivedSessionId = new AtomicReference<>(null);
|
||||
|
||||
private McpClientTransport transport;
|
||||
|
||||
// Initialize latches for proper request synchronization
|
||||
CountDownLatch firstRequestLatch;
|
||||
|
||||
CountDownLatch secondRequestLatch;
|
||||
|
||||
CountDownLatch getRequestLatch;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
|
||||
// Initialize latches for proper synchronization
|
||||
firstRequestLatch = new CountDownLatch(1);
|
||||
secondRequestLatch = new CountDownLatch(1);
|
||||
getRequestLatch = new CountDownLatch(1);
|
||||
|
||||
server = HttpServer.create(new InetSocketAddress(PORT), 0);
|
||||
|
||||
// Configure the /mcp endpoint with dynamic response
|
||||
server.createContext("/mcp", exchange -> {
|
||||
String method = exchange.getRequestMethod();
|
||||
|
||||
if ("GET".equals(method)) {
|
||||
// This is the SSE connection attempt after session establishment
|
||||
getRequestLatch.countDown();
|
||||
// Return 405 Method Not Allowed to indicate SSE not supported
|
||||
exchange.sendResponseHeaders(405, 0);
|
||||
exchange.close();
|
||||
return;
|
||||
}
|
||||
|
||||
String requestSessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
lastReceivedSessionId.set(requestSessionId);
|
||||
|
||||
int status = serverResponseStatus.get();
|
||||
|
||||
// Track which request this is
|
||||
if (firstRequestLatch.getCount() > 0) {
|
||||
// // First request - should have no session ID
|
||||
firstRequestLatch.countDown();
|
||||
}
|
||||
else if (secondRequestLatch.getCount() > 0) {
|
||||
// Second request - should have session ID
|
||||
secondRequestLatch.countDown();
|
||||
}
|
||||
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
|
||||
// Don't include session ID in 404 and 400 responses - the implementation
|
||||
// checks if the transport has a session stored locally
|
||||
String responseSessionId = currentServerSessionId.get();
|
||||
if (responseSessionId != null && status == 200) {
|
||||
exchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId);
|
||||
}
|
||||
if (status == 200) {
|
||||
String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}";
|
||||
exchange.sendResponseHeaders(200, response.length());
|
||||
exchange.getResponseBody().write(response.getBytes());
|
||||
}
|
||||
else {
|
||||
exchange.sendResponseHeaders(status, 0);
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
|
||||
server.setExecutor(null);
|
||||
server.start();
|
||||
|
||||
transport = WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(HOST)).build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
StepVerifier.create(transport.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that 404 response WITHOUT session ID throws McpTransportException (not
|
||||
* SessionNotFoundException)
|
||||
*/
|
||||
@Test
|
||||
void test404WithoutSessionId() {
|
||||
serverResponseStatus.set(404);
|
||||
currentServerSessionId.set(null); // No session ID in response
|
||||
|
||||
var testMessage = createTestMessage();
|
||||
|
||||
StepVerifier.create(transport.sendMessage(testMessage))
|
||||
.expectErrorMatches(throwable -> throwable instanceof McpTransportException
|
||||
&& throwable.getMessage().contains("Not Found") && throwable.getMessage().contains("404")
|
||||
&& !(throwable instanceof McpTransportSessionNotFoundException))
|
||||
.verify(Duration.ofSeconds(5));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that 404 response WITH session ID throws McpTransportSessionNotFoundException
|
||||
* Fixed version using proper async coordination
|
||||
*/
|
||||
@Test
|
||||
void test404WithSessionId() throws InterruptedException {
|
||||
// First establish a session
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("test-session-123");
|
||||
|
||||
// Set up exception handler to verify session invalidation
|
||||
@SuppressWarnings("unchecked")
|
||||
Consumer<Throwable> exceptionHandler = mock(Consumer.class);
|
||||
transport.setExceptionHandler(exceptionHandler);
|
||||
|
||||
// Connect with handler
|
||||
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
|
||||
|
||||
// Send initial message to establish session
|
||||
var testMessage = createTestMessage();
|
||||
|
||||
// Send first message to establish session
|
||||
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
|
||||
|
||||
// Wait for first request to complete
|
||||
assertThat(firstRequestLatch.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
|
||||
// Wait for the GET request (SSE connection attempt) to complete
|
||||
assertThat(getRequestLatch.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
|
||||
// Now return 404 for next request
|
||||
serverResponseStatus.set(404);
|
||||
|
||||
// Use delaySubscription to ensure session is fully processed before next
|
||||
// request
|
||||
StepVerifier.create(Mono.delay(Duration.ofMillis(200)).then(transport.sendMessage(testMessage)))
|
||||
.expectError(McpTransportSessionNotFoundException.class)
|
||||
.verify(Duration.ofSeconds(5));
|
||||
|
||||
// Wait for second request to be made
|
||||
assertThat(secondRequestLatch.await(5, TimeUnit.SECONDS)).isTrue();
|
||||
|
||||
// Verify the second request included the session ID
|
||||
assertThat(lastReceivedSessionId.get()).isEqualTo("test-session-123");
|
||||
|
||||
// Verify exception handler was called with SessionNotFoundException using
|
||||
// timeout
|
||||
verify(exceptionHandler, timeout(5000)).accept(any(McpTransportSessionNotFoundException.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that 400 response WITHOUT session ID throws McpTransportException (not
|
||||
* SessionNotFoundException)
|
||||
*/
|
||||
@Test
|
||||
void test400WithoutSessionId() {
|
||||
serverResponseStatus.set(400);
|
||||
currentServerSessionId.set(null); // No session ID
|
||||
|
||||
var testMessage = createTestMessage();
|
||||
|
||||
StepVerifier.create(transport.sendMessage(testMessage))
|
||||
.expectErrorMatches(throwable -> throwable instanceof McpTransportException
|
||||
&& throwable.getMessage().contains("Bad Request") && throwable.getMessage().contains("400")
|
||||
&& !(throwable instanceof McpTransportSessionNotFoundException))
|
||||
.verify(Duration.ofSeconds(5));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that 400 response WITH session ID throws McpTransportSessionNotFoundException
|
||||
* Fixed version using proper async coordination
|
||||
*/
|
||||
@Test
|
||||
void test400WithSessionId() throws InterruptedException {
|
||||
|
||||
// First establish a session
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("test-session-456");
|
||||
|
||||
// Set up exception handler
|
||||
@SuppressWarnings("unchecked")
|
||||
Consumer<Throwable> exceptionHandler = mock(Consumer.class);
|
||||
transport.setExceptionHandler(exceptionHandler);
|
||||
|
||||
// Connect with handler
|
||||
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
|
||||
|
||||
// Send initial message to establish session
|
||||
var testMessage = createTestMessage();
|
||||
|
||||
// Send first message to establish session
|
||||
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
|
||||
|
||||
// Wait for first request to complete
|
||||
boolean firstCompleted = firstRequestLatch.await(5, TimeUnit.SECONDS);
|
||||
assertThat(firstCompleted).isTrue();
|
||||
|
||||
// Wait for the GET request (SSE connection attempt) to complete
|
||||
boolean getCompleted = getRequestLatch.await(5, TimeUnit.SECONDS);
|
||||
assertThat(getCompleted).isTrue();
|
||||
|
||||
// Now return 400 for next request (simulating unknown session ID)
|
||||
serverResponseStatus.set(400);
|
||||
|
||||
// Use delaySubscription to ensure session is fully processed before next
|
||||
// request
|
||||
StepVerifier.create(Mono.delay(Duration.ofMillis(200)).then(transport.sendMessage(testMessage)))
|
||||
.expectError(McpTransportSessionNotFoundException.class)
|
||||
.verify(Duration.ofSeconds(5));
|
||||
|
||||
// Wait for second request to be made
|
||||
boolean secondCompleted = secondRequestLatch.await(5, TimeUnit.SECONDS);
|
||||
assertThat(secondCompleted).isTrue();
|
||||
|
||||
// Verify the second request included the session ID
|
||||
assertThat(lastReceivedSessionId.get()).isEqualTo("test-session-456");
|
||||
|
||||
// Verify exception handler was called with timeout
|
||||
verify(exceptionHandler, timeout(5000)).accept(any(McpTransportSessionNotFoundException.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test session recovery after SessionNotFoundException Fixed version using reactive
|
||||
* patterns and proper synchronization
|
||||
*/
|
||||
@Test
|
||||
void testSessionRecoveryAfter404() {
|
||||
// First establish a session
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("session-1");
|
||||
|
||||
// Send initial message to establish session
|
||||
var testMessage = createTestMessage();
|
||||
|
||||
// Use Mono.defer to ensure proper sequencing
|
||||
Mono<Void> establishSession = transport.sendMessage(testMessage).then(Mono.defer(() -> {
|
||||
// Simulate session loss - return 404
|
||||
serverResponseStatus.set(404);
|
||||
return transport.sendMessage(testMessage).onErrorResume(McpTransportSessionNotFoundException.class, e -> {
|
||||
// Expected error, continue with recovery
|
||||
return Mono.empty();
|
||||
});
|
||||
})).then(Mono.defer(() -> {
|
||||
// Now server is back with new session
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("session-2");
|
||||
lastReceivedSessionId.set(null); // Reset to verify new session
|
||||
|
||||
// Should be able to establish new session
|
||||
return transport.sendMessage(testMessage);
|
||||
})).then(Mono.defer(() -> {
|
||||
// Verify no session ID was sent (since old session was invalidated)
|
||||
assertThat(lastReceivedSessionId.get()).isNull();
|
||||
|
||||
// Next request should use the new session ID
|
||||
return transport.sendMessage(testMessage);
|
||||
})).doOnSuccess(v -> {
|
||||
// Session ID should now be sent with requests
|
||||
assertThat(lastReceivedSessionId.get()).isEqualTo("session-2");
|
||||
});
|
||||
|
||||
StepVerifier.create(establishSession).verifyComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that reconnect (GET request) also properly handles 404/400 errors Fixed
|
||||
* version with proper async handling
|
||||
*/
|
||||
@Test
|
||||
void testReconnectErrorHandling() throws InterruptedException {
|
||||
// Initialize latch for SSE connection
|
||||
CountDownLatch sseConnectionLatch = new CountDownLatch(1);
|
||||
|
||||
// Set up SSE endpoint for GET requests
|
||||
server.createContext("/mcp-sse", exchange -> {
|
||||
String method = exchange.getRequestMethod();
|
||||
String requestSessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
|
||||
if ("GET".equals(method)) {
|
||||
sseConnectionLatch.countDown();
|
||||
int status = serverResponseStatus.get();
|
||||
|
||||
if (status == 404 && requestSessionId != null) {
|
||||
// 404 with session ID - should trigger SessionNotFoundException
|
||||
exchange.sendResponseHeaders(404, 0);
|
||||
}
|
||||
else if (status == 404) {
|
||||
// 404 without session ID - should trigger McpTransportException
|
||||
exchange.sendResponseHeaders(404, 0);
|
||||
}
|
||||
else {
|
||||
// Normal SSE response
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
// Send a test SSE event
|
||||
String sseData = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{}}\n\n";
|
||||
exchange.getResponseBody().write(sseData.getBytes());
|
||||
}
|
||||
}
|
||||
else {
|
||||
// POST request handling
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
String responseSessionId = currentServerSessionId.get();
|
||||
if (responseSessionId != null) {
|
||||
exchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId);
|
||||
}
|
||||
String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}";
|
||||
exchange.sendResponseHeaders(200, response.length());
|
||||
exchange.getResponseBody().write(response.getBytes());
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
|
||||
// Test with session ID - should get SessionNotFoundException
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("sse-session-1");
|
||||
|
||||
var transport = WebClientStreamableHttpTransport.builder(WebClient.builder().baseUrl(HOST))
|
||||
.endpoint("/mcp-sse")
|
||||
.openConnectionOnStartup(true) // This will trigger GET request on connect
|
||||
.build();
|
||||
|
||||
// First connect successfully
|
||||
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
|
||||
|
||||
// Wait for SSE connection to be established
|
||||
boolean connected = sseConnectionLatch.await(5, TimeUnit.SECONDS);
|
||||
assertThat(connected).isTrue();
|
||||
|
||||
// Send message to establish session
|
||||
var testMessage = createTestMessage();
|
||||
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
|
||||
|
||||
// Clean up
|
||||
StepVerifier.create(transport.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
private McpSchema.JSONRPCRequest createTestMessage() {
|
||||
var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26,
|
||||
McpSchema.ClientCapabilities.builder().roots(true).build(),
|
||||
new McpSchema.Implementation("Test Client", "1.0.0"));
|
||||
return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id",
|
||||
initializeRequest);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -5,9 +5,7 @@
|
|||
package io.modelcontextprotocol.server;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.springframework.http.server.reactive.HttpHandler;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
package io.modelcontextprotocol.server;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxSseServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.transport.WebFluxStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.McpStreamableServerTransportProvider;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/*
|
||||
* Copyright 2024 - 2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import java.io.IOException;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<parent>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-parent</artifactId>
|
||||
<version>0.11.0-SNAPSHOT</version>
|
||||
<version>0.11.2</version>
|
||||
<relativePath>../../pom.xml</relativePath>
|
||||
</parent>
|
||||
<artifactId>mcp-spring-webmvc</artifactId>
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp</artifactId>
|
||||
<version>0.11.0-SNAPSHOT</version>
|
||||
<version>0.11.2</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
|
|
@ -37,14 +37,14 @@
|
|||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-test</artifactId>
|
||||
<version>0.11.0-SNAPSHOT</version>
|
||||
<version>0.11.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>io.modelcontextprotocol.sdk</groupId>
|
||||
<artifactId>mcp-spring-webflux</artifactId>
|
||||
<version>0.11.0-SNAPSHOT</version>
|
||||
<version>0.11.2</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ package io.modelcontextprotocol.server.transport;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
|
@ -16,6 +17,7 @@ import io.modelcontextprotocol.spec.McpError;
|
|||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpServerTransport;
|
||||
import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.spec.McpServerSession;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.KeepAliveScheduler;
|
||||
|
|
@ -210,8 +212,8 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
|
|||
}
|
||||
|
||||
@Override
|
||||
public String protocolVersion() {
|
||||
return "2024-11-05";
|
||||
public List<String> protocolVersions() {
|
||||
return List.of(ProtocolVersions.MCP_2024_11_05);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
/*
|
||||
* Copyright 2024-2024 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue