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
2 changed files with 221 additions and 34 deletions
|
|
@ -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