fix the baseUrl is configured with a trailing slash
Signed-off-by: lance <leehaut@gmail.com> Signed-off-by: Daniel Garnier-Moiroux <git@garnier.wf>
This commit is contained in:
parent
67f8eabb7a
commit
86991c1e49
4 changed files with 221 additions and 34 deletions
|
|
@ -14,9 +14,9 @@ import java.util.UUID;
|
|||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
|
|
@ -69,7 +69,9 @@ import reactor.core.publisher.Mono;
|
|||
@WebServlet(asyncSupported = true)
|
||||
public class HttpServletSseServerTransportProvider extends HttpServlet implements McpServerTransportProvider {
|
||||
|
||||
/** Logger for this class */
|
||||
/**
|
||||
* Logger for this class
|
||||
*/
|
||||
private static final Logger logger = LoggerFactory.getLogger(HttpServletSseServerTransportProvider.class);
|
||||
|
||||
public static final String UTF_8 = "UTF-8";
|
||||
|
|
@ -78,38 +80,60 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
|
|||
|
||||
public static final String FAILED_TO_SEND_ERROR_RESPONSE = "Failed to send error response: {}";
|
||||
|
||||
/** Default endpoint path for SSE connections */
|
||||
/**
|
||||
* Default endpoint path for SSE connections
|
||||
*/
|
||||
public static final String DEFAULT_SSE_ENDPOINT = "/sse";
|
||||
|
||||
/** Event type for regular messages */
|
||||
/**
|
||||
* Event type for regular messages
|
||||
*/
|
||||
public static final String MESSAGE_EVENT_TYPE = "message";
|
||||
|
||||
/** Event type for endpoint information */
|
||||
/**
|
||||
* Event type for endpoint information
|
||||
*/
|
||||
public static final String ENDPOINT_EVENT_TYPE = "endpoint";
|
||||
|
||||
public static final String SESSION_ID = "sessionId";
|
||||
|
||||
public static final String DEFAULT_BASE_URL = "";
|
||||
|
||||
/** JSON mapper for serialization/deserialization */
|
||||
/**
|
||||
* JSON mapper for serialization/deserialization
|
||||
*/
|
||||
private final McpJsonMapper jsonMapper;
|
||||
|
||||
/** Base URL for the server transport */
|
||||
/**
|
||||
* Base URL for the server transport
|
||||
*/
|
||||
private final String baseUrl;
|
||||
|
||||
/** The endpoint path for handling client messages */
|
||||
/**
|
||||
* The endpoint path for handling client messages
|
||||
*/
|
||||
private final String messageEndpoint;
|
||||
|
||||
/** The endpoint path for handling SSE connections */
|
||||
/**
|
||||
* The endpoint path for handling SSE connections
|
||||
*/
|
||||
private final String sseEndpoint;
|
||||
|
||||
/** Map of active client sessions, keyed by session ID */
|
||||
/**
|
||||
* Map of active client sessions, keyed by session ID
|
||||
*/
|
||||
private final Map<String, McpServerSession> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
private McpTransportContextExtractor<HttpServletRequest> contextExtractor;
|
||||
|
||||
/** Flag indicating if the transport is in the process of shutting down */
|
||||
/**
|
||||
* Flag indicating if the transport is in the process of shutting down
|
||||
*/
|
||||
private final AtomicBoolean isClosing = new AtomicBoolean(false);
|
||||
|
||||
/** Session factory for creating new sessions */
|
||||
/**
|
||||
* Session factory for creating new sessions
|
||||
*/
|
||||
private McpServerSession.Factory sessionFactory;
|
||||
|
||||
/**
|
||||
|
|
@ -243,7 +267,22 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
|
|||
this.sessions.put(sessionId, session);
|
||||
|
||||
// Send initial endpoint event
|
||||
this.sendEvent(writer, ENDPOINT_EVENT_TYPE, this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId);
|
||||
this.sendEvent(writer, ENDPOINT_EVENT_TYPE, buildEndpointUrl(sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the full message endpoint URL by combining the base URL, message path,
|
||||
* and the required session_id query parameter.
|
||||
* @param sessionId the unique session identifier
|
||||
* @return the fully qualified endpoint URL as a string
|
||||
*/
|
||||
private String buildEndpointUrl(String sessionId) {
|
||||
// for WebMVC compatibility
|
||||
if (this.baseUrl.endsWith("/")) {
|
||||
return this.baseUrl.substring(0, this.baseUrl.length() - 1) + this.messageEndpoint + "?sessionId="
|
||||
+ sessionId;
|
||||
}
|
||||
return this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -434,8 +473,8 @@ public class HttpServletSseServerTransportProvider extends HttpServlet implement
|
|||
* Converts data from one type to another using the configured JsonMapper.
|
||||
* @param data The source data object to convert
|
||||
* @param typeRef The target type reference
|
||||
* @return The converted object of type T
|
||||
* @param <T> The target type
|
||||
* @return The converted object of type T
|
||||
*/
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ import java.time.Duration;
|
|||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
|
|
@ -22,7 +21,6 @@ import io.modelcontextprotocol.spec.McpServerTransportProvider;
|
|||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.KeepAliveScheduler;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.Exceptions;
|
||||
|
|
@ -37,6 +35,7 @@ import org.springframework.web.reactive.function.server.RouterFunction;
|
|||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Server-side implementation of the MCP (Model Context Protocol) HTTP transport using
|
||||
|
|
@ -95,6 +94,8 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
|
|||
*/
|
||||
public static final String DEFAULT_SSE_ENDPOINT = "/sse";
|
||||
|
||||
public static final String SESSION_ID = "sessionId";
|
||||
|
||||
public static final String DEFAULT_BASE_URL = "";
|
||||
|
||||
private final McpJsonMapper jsonMapper;
|
||||
|
|
@ -224,6 +225,7 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
|
|||
// FIXME: This javadoc makes claims about using isClosing flag but it's not
|
||||
// actually
|
||||
// doing that.
|
||||
|
||||
/**
|
||||
* Initiates a graceful shutdown of all the sessions. This method ensures all active
|
||||
* sessions are properly closed and cleaned up.
|
||||
|
|
@ -286,10 +288,8 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
|
|||
|
||||
// Send initial endpoint event
|
||||
logger.debug("Sending initial endpoint event to session: {}", sessionId);
|
||||
sink.next(ServerSentEvent.builder()
|
||||
.event(ENDPOINT_EVENT_TYPE)
|
||||
.data(this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId)
|
||||
.build());
|
||||
sink.next(
|
||||
ServerSentEvent.builder().event(ENDPOINT_EVENT_TYPE).data(buildEndpointUrl(sessionId)).build());
|
||||
sink.onCancel(() -> {
|
||||
logger.debug("Session {} cancelled", sessionId);
|
||||
sessions.remove(sessionId);
|
||||
|
|
@ -297,6 +297,21 @@ public class WebFluxSseServerTransportProvider implements McpServerTransportProv
|
|||
}).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)), ServerSentEvent.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the full message endpoint URL by combining the base URL, message path,
|
||||
* and the required session_id query parameter.
|
||||
* @param sessionId the unique session identifier
|
||||
* @return the fully qualified endpoint URL as a string
|
||||
*/
|
||||
private String buildEndpointUrl(String sessionId) {
|
||||
// for WebMVC compatibility
|
||||
return UriComponentsBuilder.fromUriString(this.baseUrl)
|
||||
.path(this.messageEndpoint)
|
||||
.queryParam(SESSION_ID, sessionId)
|
||||
.build()
|
||||
.toUriString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming JSON-RPC messages from clients. Deserializes the message and
|
||||
* processes it through the configured message handler.
|
||||
|
|
|
|||
|
|
@ -11,20 +11,18 @@ import java.util.UUID;
|
|||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.json.TypeRef;
|
||||
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.server.McpTransportContextExtractor;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
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.spec.McpServerSession;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.KeepAliveScheduler;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
|
@ -36,6 +34,7 @@ import org.springframework.web.servlet.function.RouterFunctions;
|
|||
import org.springframework.web.servlet.function.ServerRequest;
|
||||
import org.springframework.web.servlet.function.ServerResponse;
|
||||
import org.springframework.web.servlet.function.ServerResponse.SseBuilder;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
/**
|
||||
* Server-side implementation of the Model Context Protocol (MCP) transport layer using
|
||||
|
|
@ -87,6 +86,8 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
|
|||
*/
|
||||
public static final String ENDPOINT_EVENT_TYPE = "endpoint";
|
||||
|
||||
public static final String SESSION_ID = "sessionId";
|
||||
|
||||
/**
|
||||
* Default SSE endpoint path as specified by the MCP transport specification.
|
||||
*/
|
||||
|
|
@ -275,9 +276,7 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
|
|||
this.sessions.put(sessionId, session);
|
||||
|
||||
try {
|
||||
sseBuilder.id(sessionId)
|
||||
.event(ENDPOINT_EVENT_TYPE)
|
||||
.data(this.baseUrl + this.messageEndpoint + "?sessionId=" + sessionId);
|
||||
sseBuilder.id(sessionId).event(ENDPOINT_EVENT_TYPE).data(buildEndpointUrl(sessionId));
|
||||
}
|
||||
catch (Exception e) {
|
||||
logger.error("Failed to send initial endpoint event: {}", e.getMessage());
|
||||
|
|
@ -292,6 +291,21 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs the full message endpoint URL by combining the base URL, message path,
|
||||
* and the required session_id query parameter.
|
||||
* @param sessionId the unique session identifier
|
||||
* @return the fully qualified endpoint URL as a string
|
||||
*/
|
||||
private String buildEndpointUrl(String sessionId) {
|
||||
// for WebMVC compatibility
|
||||
return UriComponentsBuilder.fromUriString(this.baseUrl)
|
||||
.path(this.messageEndpoint)
|
||||
.queryParam(SESSION_ID, sessionId)
|
||||
.build()
|
||||
.toUriString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming JSON-RPC messages from clients. This method:
|
||||
* <ul>
|
||||
|
|
@ -308,11 +322,11 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
|
|||
return ServerResponse.status(HttpStatus.SERVICE_UNAVAILABLE).body("Server is shutting down");
|
||||
}
|
||||
|
||||
if (request.param("sessionId").isEmpty()) {
|
||||
if (request.param(SESSION_ID).isEmpty()) {
|
||||
return ServerResponse.badRequest().body(new McpError("Session ID missing in message endpoint"));
|
||||
}
|
||||
|
||||
String sessionId = request.param("sessionId").get();
|
||||
String sessionId = request.param(SESSION_ID).get();
|
||||
McpServerSession session = sessions.get(sessionId);
|
||||
|
||||
if (session == null) {
|
||||
|
|
@ -327,9 +341,9 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
|
|||
|
||||
// Process the message through the session's handle method
|
||||
session.handle(message).contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext)).block(); // Block
|
||||
// for
|
||||
// WebMVC
|
||||
// compatibility
|
||||
// for
|
||||
// WebMVC
|
||||
// compatibility
|
||||
|
||||
return ServerResponse.ok().build();
|
||||
}
|
||||
|
|
@ -398,8 +412,8 @@ public class WebMvcSseServerTransportProvider implements McpServerTransportProvi
|
|||
* Converts data from one type to another using the configured McpJsonMapper.
|
||||
* @param data The source data object to convert
|
||||
* @param typeRef The target type reference
|
||||
* @return The converted object of type T
|
||||
* @param <T> The target type
|
||||
* @return The converted object of type T
|
||||
*/
|
||||
@Override
|
||||
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright 2025 - 2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.server.transport;
|
||||
|
||||
import io.modelcontextprotocol.client.McpClient;
|
||||
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
|
||||
import io.modelcontextprotocol.common.McpTransportContext;
|
||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||
import io.modelcontextprotocol.server.McpServer;
|
||||
import io.modelcontextprotocol.server.TestUtil;
|
||||
import io.modelcontextprotocol.server.TomcatTestUtil;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import org.apache.catalina.LifecycleException;
|
||||
import org.apache.catalina.LifecycleState;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
import org.springframework.web.servlet.function.RouterFunction;
|
||||
import org.springframework.web.servlet.function.ServerResponse;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration tests for WebMvcSseServerTransportProvider
|
||||
*
|
||||
* @author lance
|
||||
*/
|
||||
class WebMvcSseServerTransportProviderTests {
|
||||
|
||||
private static final int PORT = TestUtil.findAvailablePort();
|
||||
|
||||
private static final String CUSTOM_CONTEXT_PATH = "";
|
||||
|
||||
private static final String MESSAGE_ENDPOINT = "/mcp/message";
|
||||
|
||||
private WebMvcSseServerTransportProvider mcpServerTransportProvider;
|
||||
|
||||
McpClient.SyncSpec clientBuilder;
|
||||
|
||||
private TomcatTestUtil.TomcatServer tomcatServer;
|
||||
|
||||
@BeforeEach
|
||||
public void before() {
|
||||
tomcatServer = TomcatTestUtil.createTomcatServer(CUSTOM_CONTEXT_PATH, PORT, TestConfig.class);
|
||||
|
||||
try {
|
||||
tomcatServer.tomcat().start();
|
||||
assertThat(tomcatServer.tomcat().getServer().getState()).isEqualTo(LifecycleState.STARTED);
|
||||
}
|
||||
catch (Exception e) {
|
||||
throw new RuntimeException("Failed to start Tomcat", e);
|
||||
}
|
||||
|
||||
HttpClientSseClientTransport transport = HttpClientSseClientTransport.builder("http://localhost:" + PORT)
|
||||
.sseEndpoint(WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT)
|
||||
.build();
|
||||
|
||||
clientBuilder = McpClient.sync(transport);
|
||||
mcpServerTransportProvider = tomcatServer.appContext().getBean(WebMvcSseServerTransportProvider.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validBaseUrl() {
|
||||
McpServer.async(mcpServerTransportProvider).serverInfo("test-server", "1.0.0").build();
|
||||
try (var client = clientBuilder.clientInfo(new McpSchema.Implementation("Sample " + "client", "0.0.0"))
|
||||
.build()) {
|
||||
assertThat(client.initialize()).isNotNull();
|
||||
}
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
public void after() {
|
||||
if (mcpServerTransportProvider != null) {
|
||||
mcpServerTransportProvider.closeGracefully().block();
|
||||
}
|
||||
if (tomcatServer.appContext() != null) {
|
||||
tomcatServer.appContext().close();
|
||||
}
|
||||
if (tomcatServer.tomcat() != null) {
|
||||
try {
|
||||
tomcatServer.tomcat().stop();
|
||||
tomcatServer.tomcat().destroy();
|
||||
}
|
||||
catch (LifecycleException e) {
|
||||
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
static class TestConfig {
|
||||
|
||||
@Bean
|
||||
public WebMvcSseServerTransportProvider webMvcSseServerTransportProvider() {
|
||||
|
||||
return WebMvcSseServerTransportProvider.builder()
|
||||
.baseUrl("http://localhost:" + PORT + "/")
|
||||
.messageEndpoint(MESSAGE_ENDPOINT)
|
||||
.sseEndpoint(WebMvcSseServerTransportProvider.DEFAULT_SSE_ENDPOINT)
|
||||
.jsonMapper(McpJsonMapper.getDefault())
|
||||
.contextExtractor(req -> McpTransportContext.EMPTY)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public RouterFunction<ServerResponse> routerFunction(WebMvcSseServerTransportProvider transportProvider) {
|
||||
return transportProvider.getRouterFunction();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in a new issue