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

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