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:
lance 2025-10-30 22:57:58 +08:00 • committed by Daniel Garnier-Moiroux
commit 86991c1e49
4 changed files with 221 additions and 34 deletions

View file

@ -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) {

View file

@ -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.

View file

@ -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) {

View file

@ -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();
}
}
}