Skip to content

Commit 344f1b8

Browse files
nirikashtzolov
authored andcommitted
feat(mcp): resolve absolute and relative message endpoint URIs (modelcontextprotocol#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 <[email protected]>
1 parent 734d173 commit 344f1b8

File tree

7 files changed

+136
-10
lines changed

7 files changed

+136
-10
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
A set of projects that provide Java SDK integration for the [Model Context Protocol](https://modelcontextprotocol.org/docs/concepts/architecture).
55
This SDK enables Java applications to interact with AI models and tools through a standardized interface, supporting both synchronous and asynchronous communication patterns.
66

7-
## 📚 Reference Documentation
7+
## 📚 Reference Documentation
88

99
#### MCP Java SDK documentation
1010
For comprehensive guides and SDK API documentation, visit the [MCP Java SDK Reference Documentation](https://modelcontextprotocol.io/sdk/java/mcp-overview).

mcp/pom.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,26 @@
126126
<version>${junit.version}</version>
127127
<scope>test</scope>
128128
</dependency>
129+
<dependency>
130+
<groupId>org.junit.jupiter</groupId>
131+
<artifactId>junit-jupiter-params</artifactId>
132+
<version>${junit.version}</version>
133+
<scope>test</scope>
134+
</dependency>
129135
<dependency>
130136
<groupId>org.mockito</groupId>
131137
<artifactId>mockito-core</artifactId>
132138
<version>${mockito.version}</version>
133139
<scope>test</scope>
134140
</dependency>
141+
142+
<!-- Mockito cannot mock this class: class java.net.http.HttpClient. the bytebuddy helps. -->
143+
<dependency>
144+
<groupId>net.bytebuddy</groupId>
145+
<artifactId>byte-buddy</artifactId>
146+
<version>${byte-buddy.version}</version>
147+
<scope>test</scope>
148+
</dependency>
135149
<dependency>
136150
<groupId>io.projectreactor</groupId>
137151
<artifactId>reactor-test</artifactId>

mcp/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import io.modelcontextprotocol.spec.McpSchema;
2525
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
2626
import io.modelcontextprotocol.util.Assert;
27+
import io.modelcontextprotocol.util.Utils;
2728
import org.slf4j.Logger;
2829
import org.slf4j.LoggerFactory;
2930
import reactor.core.publisher.Mono;
@@ -69,7 +70,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
6970
private static final String DEFAULT_SSE_ENDPOINT = "/sse";
7071

7172
/** Base URI for the MCP server */
72-
private final String baseUri;
73+
private final URI baseUri;
7374

7475
/** SSE endpoint path */
7576
private final String sseEndpoint;
@@ -178,7 +179,7 @@ public HttpClientSseClientTransport(HttpClient.Builder clientBuilder, HttpReques
178179
Assert.hasText(sseEndpoint, "sseEndpoint must not be empty");
179180
Assert.notNull(httpClient, "httpClient must not be null");
180181
Assert.notNull(requestBuilder, "requestBuilder must not be null");
181-
this.baseUri = baseUri;
182+
this.baseUri = URI.create(baseUri);
182183
this.sseEndpoint = sseEndpoint;
183184
this.objectMapper = objectMapper;
184185
this.httpClient = httpClient;
@@ -340,7 +341,8 @@ public Mono<Void> connect(Function<Mono<JSONRPCMessage>, Mono<JSONRPCMessage>> h
340341
CompletableFuture<Void> future = new CompletableFuture<>();
341342
connectionFuture.set(future);
342343

343-
sseClient.subscribe(this.baseUri + this.sseEndpoint, new FlowSseClient.SseEventHandler() {
344+
URI clientUri = Utils.resolveUri(this.baseUri, this.sseEndpoint);
345+
sseClient.subscribe(clientUri.toString(), new FlowSseClient.SseEventHandler() {
344346
@Override
345347
public void onEvent(SseEvent event) {
346348
if (isClosing) {
@@ -412,7 +414,8 @@ public Mono<Void> sendMessage(JSONRPCMessage message) {
412414

413415
try {
414416
String jsonText = this.objectMapper.writeValueAsString(message);
415-
HttpRequest request = this.requestBuilder.uri(URI.create(this.baseUri + endpoint))
417+
URI requestUri = Utils.resolveUri(baseUri, endpoint);
418+
HttpRequest request = this.requestBuilder.uri(requestUri)
416419
.POST(HttpRequest.BodyPublishers.ofString(jsonText))
417420
.build();
418421

mcp/src/main/java/io/modelcontextprotocol/util/Utils.java

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44

55
package io.modelcontextprotocol.util;
66

7+
import reactor.util.annotation.Nullable;
8+
9+
import java.net.URI;
710
import java.util.Collection;
811
import java.util.Map;
912

10-
import reactor.util.annotation.Nullable;
11-
1213
/**
1314
* Miscellaneous utility methods.
1415
*
@@ -52,4 +53,55 @@ public static boolean isEmpty(@Nullable Map<?, ?> map) {
5253
return (map == null || map.isEmpty());
5354
}
5455

56+
/**
57+
* Resolves the given endpoint URL against the base URL.
58+
* <ul>
59+
* <li>If the endpoint URL is relative, it will be resolved against the base URL.</li>
60+
* <li>If the endpoint URL is absolute, it will be validated to ensure it matches the
61+
* base URL's scheme, authority, and path prefix.</li>
62+
* <li>If validation fails for an absolute URL, an {@link IllegalArgumentException} is
63+
* thrown.</li>
64+
* </ul>
65+
* @param baseUrl The base URL (must be absolute)
66+
* @param endpointUrl The endpoint URL (can be relative or absolute)
67+
* @return The resolved endpoint URI
68+
* @throws IllegalArgumentException If the absolute endpoint URL does not match the
69+
* base URL or URI is malformed
70+
*/
71+
public static URI resolveUri(URI baseUrl, String endpointUrl) {
72+
URI endpointUri = URI.create(endpointUrl);
73+
if (endpointUri.isAbsolute() && !isUnderBaseUri(baseUrl, endpointUri)) {
74+
throw new IllegalArgumentException("Absolute endpoint URL does not match the base URL.");
75+
}
76+
else {
77+
return baseUrl.resolve(endpointUri);
78+
}
79+
}
80+
81+
/**
82+
* Checks if the given absolute endpoint URI falls under the base URI. It validates
83+
* the scheme, authority (host and port), and ensures that the base path is a prefix
84+
* of the endpoint path.
85+
* @param baseUri The base URI
86+
* @param endpointUri The endpoint URI to check
87+
* @return true if endpointUri is within baseUri's hierarchy, false otherwise
88+
*/
89+
private static boolean isUnderBaseUri(URI baseUri, URI endpointUri) {
90+
if (!baseUri.getScheme().equals(endpointUri.getScheme())
91+
|| !baseUri.getAuthority().equals(endpointUri.getAuthority())) {
92+
return false;
93+
}
94+
95+
URI normalizedBase = baseUri.normalize();
96+
URI normalizedEndpoint = endpointUri.normalize();
97+
98+
String basePath = normalizedBase.getPath();
99+
String endpointPath = normalizedEndpoint.getPath();
100+
101+
if (basePath.endsWith("/")) {
102+
basePath = basePath.substring(0, basePath.length() - 1);
103+
}
104+
return endpointPath.startsWith(basePath);
105+
}
106+
55107
}

mcp/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
import java.net.URI;
88
import java.net.http.HttpClient;
99
import java.net.http.HttpRequest;
10+
import java.net.http.HttpResponse;
1011
import java.time.Duration;
1112
import java.util.Map;
13+
import java.util.concurrent.CompletableFuture;
1214
import java.util.concurrent.atomic.AtomicBoolean;
1315
import java.util.concurrent.atomic.AtomicInteger;
1416
import java.util.concurrent.atomic.AtomicReference;
15-
import java.util.function.Consumer;
1617
import java.util.function.Function;
1718

1819
import io.modelcontextprotocol.spec.McpSchema;
@@ -21,6 +22,8 @@
2122
import org.junit.jupiter.api.BeforeEach;
2223
import org.junit.jupiter.api.Test;
2324
import org.junit.jupiter.api.Timeout;
25+
import org.mockito.ArgumentCaptor;
26+
import org.mockito.Mockito;
2427
import org.testcontainers.containers.GenericContainer;
2528
import org.testcontainers.containers.wait.strategy.Wait;
2629
import reactor.core.publisher.Mono;
@@ -31,6 +34,9 @@
3134

3235
import static org.assertj.core.api.Assertions.assertThat;
3336
import static org.assertj.core.api.Assertions.assertThatCode;
37+
import static org.mockito.ArgumentMatchers.any;
38+
import static org.mockito.Mockito.verify;
39+
import static org.mockito.Mockito.when;
3440

3541
import com.fasterxml.jackson.databind.ObjectMapper;
3642

@@ -364,4 +370,25 @@ void testChainedCustomizations() {
364370
customizedTransport.closeGracefully().block();
365371
}
366372

373+
@Test
374+
@SuppressWarnings("unchecked")
375+
void testResolvingClientEndpoint() {
376+
HttpClient httpClient = Mockito.mock(HttpClient.class);
377+
HttpResponse<Void> httpResponse = Mockito.mock(HttpResponse.class);
378+
CompletableFuture<HttpResponse<Void>> future = new CompletableFuture<>();
379+
future.complete(httpResponse);
380+
when(httpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))).thenReturn(future);
381+
382+
HttpClientSseClientTransport transport = new HttpClientSseClientTransport(httpClient, HttpRequest.newBuilder(),
383+
"http://example.com", "http://example.com/sse", new ObjectMapper());
384+
385+
transport.connect(Function.identity());
386+
387+
ArgumentCaptor<HttpRequest> httpRequestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
388+
verify(httpClient).sendAsync(httpRequestCaptor.capture(), any(HttpResponse.BodyHandler.class));
389+
assertThat(httpRequestCaptor.getValue().uri()).isEqualTo(URI.create("http://example.com/sse"));
390+
391+
transport.closeGracefully().block();
392+
}
393+
367394
}

mcp/src/test/java/io/modelcontextprotocol/util/UtilsTests.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66

77
import org.junit.jupiter.api.Test;
88

9+
import java.net.URI;
910
import java.util.Collection;
1011
import java.util.List;
1112
import java.util.Map;
1213

14+
import static org.assertj.core.api.Assertions.assertThat;
15+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
1316
import static org.junit.jupiter.api.Assertions.assertFalse;
1417
import static org.junit.jupiter.api.Assertions.assertTrue;
18+
import org.junit.jupiter.params.ParameterizedTest;
19+
import org.junit.jupiter.params.provider.CsvSource;
1520

1621
class UtilsTests {
1722

@@ -37,4 +42,28 @@ void testMapIsEmpty() {
3742
assertFalse(Utils.isEmpty(Map.of("key", "value")));
3843
}
3944

45+
@ParameterizedTest
46+
@CsvSource({
47+
// relative endpoints
48+
"http://localhost:8080/root, /api/v1, http://localhost:8080/api/v1",
49+
"http://localhost:8080/root/, api, http://localhost:8080/root/api",
50+
"http://localhost:8080, /api, http://localhost:8080/api",
51+
// absolute endpoints matching base
52+
"http://localhost:8080/root, http://localhost:8080/root/api/v1, http://localhost:8080/root/api/v1",
53+
"http://localhost:8080/root, http://localhost:8080/root, http://localhost:8080/root" })
54+
void testValidUriResolution(String baseUrl, String endpoint, String expectedResult) {
55+
URI result = Utils.resolveUri(URI.create(baseUrl), endpoint);
56+
assertThat(result.toString()).isEqualTo(expectedResult);
57+
}
58+
59+
@ParameterizedTest
60+
@CsvSource({ "http://localhost:8080/root, http://localhost:8080/other/api",
61+
"http://localhost:8080/root, http://otherhost/api",
62+
"http://localhost:8080/root, http://localhost:9090/root/api" })
63+
void testAbsoluteUriNotMatchingBase(String baseUrl, String endpoint) {
64+
assertThatThrownBy(() -> Utils.resolveUri(URI.create(baseUrl), endpoint))
65+
.isInstanceOf(IllegalArgumentException.class)
66+
.hasMessageContaining("does not match the base URL");
67+
}
68+
4069
}

pom.xml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@
6060

6161
<assert4j.version>3.26.3</assert4j.version>
6262
<junit.version>5.10.2</junit.version>
63-
<mockito.version>5.11.0</mockito.version>
63+
<mockito.version>5.17.0</mockito.version>
6464
<testcontainers.version>1.20.4</testcontainers.version>
65+
<byte-buddy.version>1.17.5</byte-buddy.version>
6566

6667
<slf4j-api.version>2.0.16</slf4j-api.version>
6768
<logback.version>1.5.15</logback.version>
@@ -356,4 +357,4 @@
356357
</repository>
357358
</repositories>
358359

359-
</project>
360+
</project>

0 commit comments

Comments
 (0)