feat(mcp): resolve absolute and relative message endpoint URIs (#150)

Improve endpoint URI handling by supporting both relative paths
and properly validated absolute URIs.

- Implement URI resolution in HttpClientSseClientTransport:
- Change baseUri field from String to URI type
- Add Utils.resolveUri method to handle both absolute and relative URIs
- Resolve relative URIs against the base URI
- Validate absolute URIs to ensure they match base URI's scheme, authority, and path
- Add parameterized tests for various URI resolution scenarios
- Add ByteBuddy dependency for HttpClient mocking and update Mockito

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
E550448 2025-04-13 12:54:48 +02:00 • committed by Christian Tzolov
commit 344f1b8383
7 changed files with 136 additions and 10 deletions

View file

@ -4,7 +4,7 @@
A set of projects that provide Java SDK integration for the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture).
This SDK enables Java applications to interact with AI models and tools through a standardized interface, supporting both synchronous and asynchronous communication patterns.
## 📚 Reference Documentation
## 📚 Reference Documentation
#### MCP Java SDK documentation
For comprehensive guides and SDK API documentation, visit the [MCP Java SDK Reference Documentation](https://modelcontextprotocol.io/sdk/java/mcp-overview).

View file

@ -126,12 +126,26 @@
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>${mockito.version}</version>
<scope>test</scope>
</dependency>
<!-- Mockito cannot mock this class: class java.net.http.HttpClient. the bytebuddy helps. -->
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>${byte-buddy.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>

View file

@ -24,6 +24,7 @@ import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
import io.modelcontextprotocol.util.Assert;
import io.modelcontextprotocol.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
@ -69,7 +70,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
/** Base URI for the MCP server */
private final String baseUri;
private final URI baseUri;
/** SSE endpoint path */
private final String sseEndpoint;
@ -178,7 +179,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
Assert.notNull(httpClient, "httpClient must not be null");
Assert.notNull(requestBuilder, "requestBuilder must not be null");
this.baseUri = baseUri;
this.baseUri = URI.create(baseUri);
this.sseEndpoint = sseEndpoint;
this.objectMapper = objectMapper;
this.httpClient = httpClient;
@ -340,7 +341,8 @@ public class HttpClientSseClientTransport implements McpClientTransport {
CompletableFuture<Void> future = new CompletableFuture<>();
connectionFuture.set(future);
sseClient.subscribe(this.baseUri + this.sseEndpoint, new FlowSseClient.SseEventHandler() {
URI clientUri = Utils.resolveUri(this.baseUri, this.sseEndpoint);
sseClient.subscribe(clientUri.toString(), new FlowSseClient.SseEventHandler() {
@Override
public void onEvent(SseEvent event) {
if (isClosing) {
@ -412,7 +414,8 @@ public class HttpClientSseClientTransport implements McpClientTransport {
try {
String jsonText = this.objectMapper.writeValueAsString(message);
HttpRequest request = this.requestBuilder.uri(URI.create(this.baseUri + endpoint))
URI requestUri = Utils.resolveUri(baseUri, endpoint);
HttpRequest request = this.requestBuilder.uri(requestUri)
.POST(HttpRequest.BodyPublishers.ofString(jsonText))
.build();

View file

@ -4,11 +4,12 @@
package io.modelcontextprotocol.util;
import reactor.util.annotation.Nullable;
import java.net.URI;
import java.util.Collection;
import java.util.Map;
import reactor.util.annotation.Nullable;
/**
* Miscellaneous utility methods.
*
@ -52,4 +53,55 @@ public final class Utils {
return (map == null || map.isEmpty());
}
/**
* Resolves the given endpoint URL against the base URL.
* <ul>
* <li>If the endpoint URL is relative, it will be resolved against the base URL.</li>
* <li>If the endpoint URL is absolute, it will be validated to ensure it matches the
* base URL's scheme, authority, and path prefix.</li>
* <li>If validation fails for an absolute URL, an {@link IllegalArgumentException} is
* thrown.</li>
* </ul>
* @param baseUrl The base URL (must be absolute)
* @param endpointUrl The endpoint URL (can be relative or absolute)
* @return The resolved endpoint URI
* @throws IllegalArgumentException If the absolute endpoint URL does not match the
* base URL or URI is malformed
*/
public static URI resolveUri(URI baseUrl, String endpointUrl) {
URI endpointUri = URI.create(endpointUrl);
if (endpointUri.isAbsolute() && !isUnderBaseUri(baseUrl, endpointUri)) {
throw new IllegalArgumentException("Absolute endpoint URL does not match the base URL.");
}
else {
return baseUrl.resolve(endpointUri);
}
}
/**
* Checks if the given absolute endpoint URI falls under the base URI. It validates
* the scheme, authority (host and port), and ensures that the base path is a prefix
* of the endpoint path.
* @param baseUri The base URI
* @param endpointUri The endpoint URI to check
* @return true if endpointUri is within baseUri's hierarchy, false otherwise
*/
private static boolean isUnderBaseUri(URI baseUri, URI endpointUri) {
if (!baseUri.getScheme().equals(endpointUri.getScheme())
|| !baseUri.getAuthority().equals(endpointUri.getAuthority())) {
return false;
}
URI normalizedBase = baseUri.normalize();
URI normalizedEndpoint = endpointUri.normalize();
String basePath = normalizedBase.getPath();
String endpointPath = normalizedEndpoint.getPath();
if (basePath.endsWith("/")) {
basePath = basePath.substring(0, basePath.length() - 1);
}
return endpointPath.startsWith(basePath);
}
}

View file

@ -7,12 +7,13 @@ package io.modelcontextprotocol.client.transport;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
import java.util.function.Function;
import io.modelcontextprotocol.spec.McpSchema;
@ -21,6 +22,8 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import reactor.core.publisher.Mono;
@ -31,6 +34,9 @@ import org.springframework.http.codec.ServerSentEvent;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -364,4 +370,25 @@ class HttpClientSseClientTransportTests {
customizedTransport.closeGracefully().block();
}
@Test
@SuppressWarnings("unchecked")
void testResolvingClientEndpoint() {
HttpClient httpClient = Mockito.mock(HttpClient.class);
HttpResponse<Void> httpResponse = Mockito.mock(HttpResponse.class);
CompletableFuture<HttpResponse<Void>> future = new CompletableFuture<>();
future.complete(httpResponse);
when(httpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(future);
HttpClientSseClientTransport transport = new HttpClientSseClientTransport(httpClient, HttpRequest.newBuilder(),
"http://example.com", "http://example.com/sse", new ObjectMapper());
transport.connect(Function.identity());
ArgumentCaptor<HttpRequest> httpRequestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).sendAsync(httpRequestCaptor.capture(), any(HttpResponse.BodyHandler.class));
assertThat(httpRequestCaptor.getValue().uri()).isEqualTo(URI.create("http://example.com/sse"));
transport.closeGracefully().block();
}
}

View file

@ -6,12 +6,17 @@ package io.modelcontextprotocol.util;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class UtilsTests {
@ -37,4 +42,28 @@ class UtilsTests {
assertFalse(Utils.isEmpty(Map.of("key", "value")));
}
@ParameterizedTest
@CsvSource({
// relative endpoints
"http://localhost:8080/root, /api/v1, http://localhost:8080/api/v1",
"http://localhost:8080/root/, api, http://localhost:8080/root/api",
"http://localhost:8080, /api, http://localhost:8080/api",
// absolute endpoints matching base
"http://localhost:8080/root, http://localhost:8080/root/api/v1, http://localhost:8080/root/api/v1",
"http://localhost:8080/root, http://localhost:8080/root, http://localhost:8080/root" })
void testValidUriResolution(String baseUrl, String endpoint, String expectedResult) {
URI result = Utils.resolveUri(URI.create(baseUrl), endpoint);
assertThat(result.toString()).isEqualTo(expectedResult);
}
@ParameterizedTest
@CsvSource({ "http://localhost:8080/root, http://localhost:8080/other/api",
"http://localhost:8080/root, http://otherhost/api",
"http://localhost:8080/root, http://localhost:9090/root/api" })
void testAbsoluteUriNotMatchingBase(String baseUrl, String endpoint) {
assertThatThrownBy(() -> Utils.resolveUri(URI.create(baseUrl), endpoint))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("does not match the base URL");
}
}

View file

@ -60,8 +60,9 @@
<assert4j.version>3.26.3</assert4j.version>
<junit.version>5.10.2</junit.version>
<mockito.version>5.11.0</mockito.version>
<mockito.version>5.17.0</mockito.version>
<testcontainers.version>1.20.4</testcontainers.version>
<byte-buddy.version>1.17.5</byte-buddy.version>
<slf4j-api.version>2.0.16</slf4j-api.version>
<logback.version>1.5.15</logback.version>
@ -356,4 +357,4 @@
</repository>
</repositories>
</project>
</project>