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
2 changed files with 136 additions and 10 deletions

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");
}
}