diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml index 637aeea16..a631cb537 100644 --- a/conformance-tests/client-jdk-http-client/pom.xml +++ b/conformance-tests/client-jdk-http-client/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.0.1 + 1.0.2 client-jdk-http-client jar @@ -28,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 1.0.1 + 1.0.2 diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml index 9390c559e..14d0b3825 100644 --- a/conformance-tests/pom.xml +++ b/conformance-tests/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.1 + 1.0.2 conformance-tests pom diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml index e7e7a13c4..4a5f79ecb 100644 --- a/conformance-tests/server-servlet/pom.xml +++ b/conformance-tests/server-servlet/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk conformance-tests - 1.0.1 + 1.0.2 server-servlet jar @@ -28,7 +28,7 @@ io.modelcontextprotocol.sdk mcp - 1.0.1 + 1.0.2 diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml index 337b00038..2cb231be5 100644 --- a/mcp-bom/pom.xml +++ b/mcp-bom/pom.xml @@ -7,7 +7,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.1 + 1.0.2 mcp-bom diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml index b55ccaaca..4461ff0a9 100644 --- a/mcp-core/pom.xml +++ b/mcp-core/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.1 + 1.0.2 mcp-core jar diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java new file mode 100644 index 000000000..4be5875db --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidator.java @@ -0,0 +1,61 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ +package io.modelcontextprotocol.client.transport; + +import java.net.URI; +import java.net.URISyntaxException; + +import io.modelcontextprotocol.util.Assert; + +/** + * Default {@link SseMessageEndpointValidator} that validates the {@code message} endpoint + * advertised by an SSE server. Message endpoints must either have the same origin as the + * SSE uri, or be a relative uri. + * + * @author Daniel Garnier-Moiroux + */ +public final class DefaultSseMessageEndpointValidator implements SseMessageEndpointValidator { + + @Override + public void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException { + Assert.hasText(messageEndpoint, "messageEndpoint must not be empty"); + + URI endpointUri; + try { + endpointUri = new URI(messageEndpoint); + } + catch (URISyntaxException ex) { + throw new InvalidSseMessageEndpointException("messageEndpoint is not a valid URI: " + ex.getMessage(), + messageEndpoint); + } + + if (endpointUri.isAbsolute() || endpointUri.getRawAuthority() != null) { + String scheme = endpointUri.getScheme(); + String host = endpointUri.getHost(); + int port = endpointUri.getPort(); + + boolean sameScheme = scheme != null && scheme.equalsIgnoreCase(sseUri.getScheme()); + boolean sameHost = host != null && host.equalsIgnoreCase(sseUri.getHost()); + boolean samePort = port == sseUri.getPort(); + + if (!sameScheme || !sameHost || !samePort) { + throw new InvalidSseMessageEndpointException( + "messageEndpoint must be a relative path or a same-origin URI", messageEndpoint); + } + } + + // Exclude path-traversal + String decodedPath = endpointUri.getPath(); + if (decodedPath != null) { + for (String segment : decodedPath.split("/", -1)) { + if (".".equals(segment) || "..".equals(segment)) { + throw new InvalidSseMessageEndpointException( + "messageEndpoint must not contain path-traversal segments", messageEndpoint); + } + } + } + + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java index be4e4cf97..7cce52de3 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransport.java @@ -16,8 +16,6 @@ import java.util.function.Consumer; import java.util.function.Function; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent; import io.modelcontextprotocol.client.transport.customizer.McpAsyncHttpClientRequestCustomizer; import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; @@ -33,6 +31,8 @@ import io.modelcontextprotocol.spec.ProtocolVersions; import io.modelcontextprotocol.util.Assert; import io.modelcontextprotocol.util.Utils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.Disposable; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -117,6 +117,11 @@ public class HttpClientSseClientTransport implements McpClientTransport { */ private final McpAsyncHttpClientRequestCustomizer httpRequestCustomizer; + /** + * Validator for the message endpoint; + */ + private final SseMessageEndpointValidator messageEndpointValidator; + /** * Creates a new transport instance with custom HTTP client builder, object mapper, * and headers. @@ -127,22 +132,26 @@ public class HttpClientSseClientTransport implements McpClientTransport { * @param jsonMapper the object mapper for JSON serialization/deserialization * @param httpRequestCustomizer customizer for the requestBuilder before executing * requests + * @param messageEndpointValidator validator for the message endpoint * @throws IllegalArgumentException if objectMapper, clientBuilder, or headers is null */ HttpClientSseClientTransport(HttpClient httpClient, HttpRequest.Builder requestBuilder, String baseUri, - String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer) { + String sseEndpoint, McpJsonMapper jsonMapper, McpAsyncHttpClientRequestCustomizer httpRequestCustomizer, + SseMessageEndpointValidator messageEndpointValidator) { Assert.notNull(jsonMapper, "jsonMapper must not be null"); Assert.hasText(baseUri, "baseUri must not be empty"); Assert.hasText(sseEndpoint, "sseEndpoint must not be empty"); Assert.notNull(httpClient, "httpClient must not be null"); Assert.notNull(requestBuilder, "requestBuilder must not be null"); Assert.notNull(httpRequestCustomizer, "httpRequestCustomizer must not be null"); + Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null"); this.baseUri = URI.create(baseUri); this.sseEndpoint = sseEndpoint; this.jsonMapper = jsonMapper; this.httpClient = httpClient; this.requestBuilder = requestBuilder; this.httpRequestCustomizer = httpRequestCustomizer; + this.messageEndpointValidator = messageEndpointValidator; } @Override @@ -178,6 +187,8 @@ public static class Builder { private Duration connectTimeout = Duration.ofSeconds(10); + private SseMessageEndpointValidator messageEndpointValidator = new DefaultSseMessageEndpointValidator(); + /** * Creates a new builder instance. */ @@ -308,6 +319,18 @@ public Builder connectTimeout(Duration connectTimeout) { return this; } + /** + * Sets the validator that ensure the message endpoint returned over the SSE + * connection is valid. + * @param messageEndpointValidator the validator + * @return this builder + */ + public Builder messageEndpointValidator(SseMessageEndpointValidator messageEndpointValidator) { + Assert.notNull(messageEndpointValidator, "messageEndpointValidator must not be null"); + this.messageEndpointValidator = messageEndpointValidator; + return this; + } + /** * Builds a new {@link HttpClientSseClientTransport} instance. * @return a new transport instance @@ -315,7 +338,8 @@ public Builder connectTimeout(Duration connectTimeout) { public HttpClientSseClientTransport build() { HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build(); return new HttpClientSseClientTransport(httpClient, requestBuilder, baseUri, sseEndpoint, - jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer); + jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper, httpRequestCustomizer, + messageEndpointValidator); } } @@ -353,6 +377,14 @@ public Mono connect(Function, Mono> h try { if (ENDPOINT_EVENT_TYPE.equals(responseEvent.sseEvent().event())) { String messageEndpointUri = responseEvent.sseEvent().data(); + try { + messageEndpointValidator.validate(uri, messageEndpointUri); + } + catch (InvalidSseMessageEndpointException e) { + sink.error(e); + this.messageEndpointSink.tryEmitError(e); + return Flux.error(e); + } if (this.messageEndpointSink.tryEmitValue(messageEndpointUri).isSuccess()) { sink.success(); return Flux.empty(); // No further processing needed diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java new file mode 100644 index 000000000..6acdfae51 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/InvalidSseMessageEndpointException.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +/** + * Exception thrown when the {@code message} endpoint returned from the SSE connection is + * not valid. + * + * @author Daniel Garnier-Moiroux + */ +public class InvalidSseMessageEndpointException extends Exception { + + private final String messageEndpoint; + + public InvalidSseMessageEndpointException(String message, String messageEndpoint) { + super(message); + this.messageEndpoint = messageEndpoint; + } + + public String getMessageEndpoint() { + return messageEndpoint; + } + +} diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java new file mode 100644 index 000000000..322e64638 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/SseMessageEndpointValidator.java @@ -0,0 +1,27 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.client.transport; + +import java.net.URI; + +/** + * Validate the that message endpoint in the SSE transport is valid. Throws + * {@link InvalidSseMessageEndpointException} when then endpoint is not valid. + * + * @author Daniel Garnier-Moiroux + */ +@FunctionalInterface +public interface SseMessageEndpointValidator { + + /** + * Validate the message endpoint coming from an SSE connection. Throws if not valid. + * @param sseUri the URI used to establish the SSE connection + * @param messageEndpoint the message endpoint from the SSE connection + * @throws InvalidSseMessageEndpointException error thrown if the message endpoint is + * not valid. + */ + void validate(URI sseUri, String messageEndpoint) throws InvalidSseMessageEndpointException; + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java new file mode 100644 index 000000000..cf2e045a1 --- /dev/null +++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/DefaultSseMessageEndpointValidatorTests.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ +package io.modelcontextprotocol.client.transport; + +import java.net.URI; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.type; + +/** + * Tests for {@link DefaultSseMessageEndpointValidator}. + * + * @author Daniel Garnier-Moiroux + */ +class DefaultSseMessageEndpointValidatorTests { + + private static final URI SSE_URI = URI.create("https://mcp.example.com/sse"); + + private final DefaultSseMessageEndpointValidator validator = new DefaultSseMessageEndpointValidator(); + + @ParameterizedTest + @ValueSource(strings = { "/messages", "messages?session=abc", "/", "https://mcp.example.com/messages" }) + void valid(String endpoint) { + assertThatCode(() -> validator.validate(SSE_URI, endpoint)).doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = { "", " ", "\t" }) + @NullSource + void invalidEmpty(String endpoint) { + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("messageEndpoint must not be empty"); + } + + @ParameterizedTest + @ValueSource(strings = { "/foo/../bar", "/foo/./bar", "../bar", "./bar", "/foo/%2E%2E/bar", "/foo/%2e/bar" }) + void invalidPathTraversal(String endpoint) { + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)) + .hasMessageContaining("must not contain path-traversal segments") + .asInstanceOf(type(InvalidSseMessageEndpointException.class)) + .extracting(InvalidSseMessageEndpointException::getMessageEndpoint) + .isEqualTo(endpoint); + } + + @ParameterizedTest + @ValueSource(strings = { "https://127.0.0.1/messages", "https://mcp.example.com:8443/messages", + "http://localhost:1234/messages", "file:///etc/passwd", "gopher://mcp.example.com/_test" }) + void invalidAbsoluteUris(String endpoint) { + // Absolute URIs must be same-origin. + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)) + .hasMessageContaining("must be a relative path or a same-origin URI") + .asInstanceOf(type(InvalidSseMessageEndpointException.class)) + .extracting(InvalidSseMessageEndpointException::getMessageEndpoint) + .isEqualTo(endpoint); + + } + + @ParameterizedTest + @ValueSource(strings = { "//example/messages", "//user:secret@example/messages", "//mcp.example.com/messages" }) + void invalidNetworkReference(String endpoint) { + // `//host/...` introduces an authority and is therefore not a pure path. + // It is missing a scheme, so it fails same-origin check. + assertThatThrownBy(() -> validator.validate(SSE_URI, endpoint)) + .hasMessageContaining("must be a relative path or a same-origin URI") + .asInstanceOf(type(InvalidSseMessageEndpointException.class)) + .extracting(InvalidSseMessageEndpointException::getMessageEndpoint) + .isEqualTo(endpoint); + } + +} diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml index ef458d87d..f1d6f7488 100644 --- a/mcp-json-jackson2/pom.xml +++ b/mcp-json-jackson2/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.1 + 1.0.2 mcp-json-jackson2 jar @@ -70,7 +70,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.0.1 + 1.0.2 com.networknt diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml index d5318ccae..ad5d4cbd9 100644 --- a/mcp-json-jackson3/pom.xml +++ b/mcp-json-jackson3/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.1 + 1.0.2 mcp-json-jackson3 jar @@ -64,7 +64,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.0.1 + 1.0.2 tools.jackson.core diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml index c8e0bdb6f..86ff090e9 100644 --- a/mcp-test/pom.xml +++ b/mcp-test/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.1 + 1.0.2 mcp-test jar @@ -24,7 +24,7 @@ io.modelcontextprotocol.sdk mcp-core - 1.0.1 + 1.0.2 @@ -159,7 +159,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 1.0.1 + 1.0.2 test @@ -170,7 +170,7 @@ io.modelcontextprotocol.sdk mcp-json-jackson2 - 1.0.1 + 1.0.2 test diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java index a24805a30..15df0791f 100644 --- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java +++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientSseClientTransportTests.java @@ -19,7 +19,6 @@ import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.JSONRPCRequest; - import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; @@ -35,13 +34,13 @@ import org.springframework.http.codec.ServerSentEvent; import org.springframework.web.util.UriComponentsBuilder; - import static io.modelcontextprotocol.util.McpJsonMapperUtils.JSON_MAPPER; 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.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.matches; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -66,6 +65,8 @@ class HttpClientSseClientTransportTests { private TestHttpClientSseClientTransport transport; + private SseMessageEndpointValidator sseMessageEndpointValidator = mock(SseMessageEndpointValidator.class); + private final McpTransportContext context = McpTransportContext.create(Map.of("some-key", "some-value")); // Test class to access protected methods @@ -75,10 +76,11 @@ static class TestHttpClientSseClientTransport extends HttpClientSseClientTranspo private Sinks.Many> events = Sinks.many().unicast().onBackpressureBuffer(); - public TestHttpClientSseClientTransport(final String baseUri) { + public TestHttpClientSseClientTransport(final String baseUri, + SseMessageEndpointValidator sseMessageEndpointValidator) { super(HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(), HttpRequest.newBuilder().header("Content-Type", "application/json"), baseUri, "/sse", JSON_MAPPER, - McpAsyncHttpClientRequestCustomizer.NOOP); + McpAsyncHttpClientRequestCustomizer.NOOP, sseMessageEndpointValidator); } public int getInboundMessageCount() { @@ -112,7 +114,7 @@ static void stopContainer() { @BeforeEach void setUp() { - transport = new TestHttpClientSseClientTransport(host); + transport = new TestHttpClientSseClientTransport(host, sseMessageEndpointValidator); transport.connect(Function.identity()).block(); } @@ -477,4 +479,44 @@ void testAsyncRequestCustomizer() { customizedTransport.closeGracefully().block(); } + @Test + void testMessageEndpointValidation() throws InvalidSseMessageEndpointException { + var uriCaptor = ArgumentCaptor.forClass(URI.class); + verify(sseMessageEndpointValidator).validate(uriCaptor.capture(), matches("/message\\?sessionId=[a-z0-9-]+")); + assertThat(uriCaptor.getValue().toString()).matches(host + "/sse"); + } + + @Test + void testMessageEndpointValidationRejects() { + TestHttpClientSseClientTransport transport = new TestHttpClientSseClientTransport(host, + (sseUri, messageEndpoint) -> { + throw new InvalidSseMessageEndpointException("boom", messageEndpoint); + }); + + try { + // fails to connect + StepVerifier.create(transport.connect(Function.identity())) + .verifyErrorMatches(HttpClientSseClientTransportTests::isInvalidEndpointError); + + // Since connection failed, there is no message endpoint, and no message can + // be sent + JSONRPCRequest testMessage = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, "test-method", "test-id", + Map.of("key", "value")); + + StepVerifier.create(transport.sendMessage(testMessage)) + .verifyErrorMatches(HttpClientSseClientTransportTests::isInvalidEndpointError); + } + finally { + transport.closeGracefully(); + } + } + + private static boolean isInvalidEndpointError(Throwable e) { + if (e instanceof InvalidSseMessageEndpointException ismee) { + return ismee.getMessageEndpoint().matches("/message\\?sessionId=[a-z0-9-]+") + && ismee.getMessage().equals("boom"); + } + return false; + } + } diff --git a/mcp/pom.xml b/mcp/pom.xml index b61f7b6ee..399f83a7e 100644 --- a/mcp/pom.xml +++ b/mcp/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.1 + 1.0.2 mcp jar @@ -25,13 +25,13 @@ io.modelcontextprotocol.sdk mcp-json-jackson3 - 1.0.1 + 1.0.2 io.modelcontextprotocol.sdk mcp-core - 1.0.1 + 1.0.2 diff --git a/pom.xml b/pom.xml index 414accb93..aa2440d04 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.modelcontextprotocol.sdk mcp-parent - 1.0.1 + 1.0.2 pom https://github.com/modelcontextprotocol/java-sdk