supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
+ private McpHttpClientAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientAuthorizationErrorHandler.NOOP;
+
/**
* Creates a new builder with the specified base URI.
* @param baseUri the base URI of the MCP server
@@ -801,6 +832,17 @@ public Builder asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer as
return this;
}
+ /**
+ * Sets the handler to be used when the server responds with HTTP 401 or HTTP 403
+ * when sending a message.
+ * @param authorizationErrorHandler the handler
+ * @return this builder
+ */
+ public Builder authorizationErrorHandler(McpHttpClientAuthorizationErrorHandler authorizationErrorHandler) {
+ this.authorizationErrorHandler = authorizationErrorHandler;
+ return this;
+ }
+
/**
* Sets the connection timeout for the HTTP client.
* @param connectTimeout the connection timeout duration
@@ -845,7 +887,7 @@ public HttpClientStreamableHttpTransport build() {
HttpClient httpClient = this.clientBuilder.connectTimeout(this.connectTimeout).build();
return new HttpClientStreamableHttpTransport(jsonMapper == null ? McpJsonDefaults.getMapper() : jsonMapper,
httpClient, requestBuilder, baseUri, endpoint, resumableStreams, openConnectionOnStartup,
- httpRequestCustomizer, supportedProtocolVersions);
+ httpRequestCustomizer, authorizationErrorHandler, supportedProtocolVersions);
}
}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java
new file mode 100644
index 000000000..31e5ae95e
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/McpHttpClientTransportAuthorizationException.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport;
+
+import java.net.http.HttpResponse;
+
+import io.modelcontextprotocol.spec.McpTransportException;
+
+/**
+ * Thrown when the MCP server responds with an authorization error (HTTP 401 or HTTP 403).
+ * Subclass of {@link McpTransportException} for targeted retry handling in
+ * {@link HttpClientStreamableHttpTransport}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class McpHttpClientTransportAuthorizationException extends McpTransportException {
+
+ private final HttpResponse.ResponseInfo responseInfo;
+
+ public McpHttpClientTransportAuthorizationException(String message, HttpResponse.ResponseInfo responseInfo) {
+ super(message);
+ this.responseInfo = responseInfo;
+ }
+
+ public HttpResponse.ResponseInfo getResponseInfo() {
+ return responseInfo;
+ }
+
+}
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java
new file mode 100644
index 000000000..c98fac61d
--- /dev/null
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandler.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+
+package io.modelcontextprotocol.client.transport.customizer;
+
+import java.net.http.HttpResponse;
+
+import io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException;
+import io.modelcontextprotocol.common.McpTransportContext;
+import org.reactivestreams.Publisher;
+import reactor.core.publisher.Mono;
+import reactor.core.scheduler.Schedulers;
+
+/**
+ * Handle security-related errors in HTTP-client based transports. This class handles MCP
+ * server responses with status code 401 and 403.
+ *
+ * @see MCP
+ * Specification: Authorization
+ * @author Daniel Garnier-Moiroux
+ */
+public interface McpHttpClientAuthorizationErrorHandler {
+
+ /**
+ * Handle authorization error (HTTP 401 or 403), and signal whether the HTTP request
+ * should be retried or not. If the publisher returns true, the original transport
+ * method (connect, sendMessage) will be replayed with the original arguments.
+ * Otherwise, the transport will throw an
+ * {@link McpHttpClientTransportAuthorizationException}, indicating the error status.
+ *
+ * If the returned {@link Publisher} errors, the error will be propagated to the
+ * calling method, to be handled by the caller.
+ *
+ * The number of retries is bounded by {@link #maxRetries()}.
+ * @param responseInfo the HTTP response information
+ * @param context the MCP client transport context
+ * @return {@link Publisher} emitting true if the original request should be replayed,
+ * false otherwise.
+ */
+ Publisher handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context);
+
+ /**
+ * Maximum number of authorization error retries the transport will attempt. When the
+ * handler signals a retry via {@link #handle}, the transport will replay the original
+ * request at most this many times. If the authorization error persists after
+ * exhausting all retries, the transport will propagate the
+ * {@link McpHttpClientTransportAuthorizationException}.
+ *
+ * Defaults to {@code 1}.
+ * @return the maximum number of retries
+ */
+ default int maxRetries() {
+ return 1;
+ }
+
+ /**
+ * A no-op handler, used in the default use-case.
+ */
+ McpHttpClientAuthorizationErrorHandler NOOP = new Noop();
+
+ /**
+ * Create a {@link McpHttpClientAuthorizationErrorHandler} from a synchronous handler.
+ * Will be subscribed on {@link Schedulers#boundedElastic()}. The handler may be
+ * blocking.
+ * @param handler the synchronous handler
+ * @return an async handler
+ */
+ static McpHttpClientAuthorizationErrorHandler fromSync(Sync handler) {
+ return (info, context) -> Mono.fromCallable(() -> handler.handle(info, context))
+ .subscribeOn(Schedulers.boundedElastic());
+ }
+
+ /**
+ * Synchronous authorization error handler.
+ */
+ interface Sync {
+
+ /**
+ * Handle authorization error (HTTP 401 or 403), and signal whether the HTTP
+ * request should be retried or not. If the return value is true, the original
+ * transport method (connect, sendMessage) will be replayed with the original
+ * arguments. Otherwise, the transport will throw an
+ * {@link McpHttpClientTransportAuthorizationException}, indicating the error
+ * status.
+ * @param responseInfo the HTTP response information
+ * @param context the MCP client transport context
+ * @return true if the original request should be replayed, false otherwise.
+ */
+ boolean handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context);
+
+ }
+
+ class Noop implements McpHttpClientAuthorizationErrorHandler {
+
+ @Override
+ public Publisher handle(HttpResponse.ResponseInfo responseInfo, McpTransportContext context) {
+ return Mono.just(false);
+ }
+
+ }
+
+}
diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java
new file mode 100644
index 000000000..2812522f5
--- /dev/null
+++ b/mcp-core/src/test/java/io/modelcontextprotocol/client/transport/customizer/McpHttpClientAuthorizationErrorHandlerTest.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2026-2026 the original author or authors.
+ */
+package io.modelcontextprotocol.client.transport.customizer;
+
+import java.net.http.HttpResponse;
+
+import io.modelcontextprotocol.common.McpTransportContext;
+import org.junit.jupiter.api.Test;
+import reactor.test.StepVerifier;
+
+import static org.mockito.Mockito.mock;
+
+/**
+ * @author Daniel Garnier-Moiroux
+ */
+class McpHttpClientAuthorizationErrorHandlerTest {
+
+ private final HttpResponse.ResponseInfo responseInfo = mock(HttpResponse.ResponseInfo.class);
+
+ private final McpTransportContext context = McpTransportContext.EMPTY;
+
+ @Test
+ void whenTrueThenRetry() {
+ McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler
+ .fromSync((info, ctx) -> true);
+ StepVerifier.create(handler.handle(responseInfo, context)).expectNext(true).verifyComplete();
+ }
+
+ @Test
+ void whenFalseThenError() {
+ McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler
+ .fromSync((info, ctx) -> false);
+ StepVerifier.create(handler.handle(responseInfo, context)).expectNext(false).verifyComplete();
+ }
+
+ @Test
+ void whenExceptionThenPropagate() {
+ McpHttpClientAuthorizationErrorHandler handler = McpHttpClientAuthorizationErrorHandler
+ .fromSync((info, ctx) -> {
+ throw new IllegalStateException("sync handler error");
+ });
+ StepVerifier.create(handler.handle(responseInfo, context))
+ .expectErrorMatches(t -> t instanceof IllegalStateException && t.getMessage().equals("sync handler error"))
+ .verify();
+ }
+
+}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
index b82d6eb2c..c4857e5b4 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
@@ -1,26 +1,24 @@
/*
- * Copyright 2025-2025 the original author or authors.
+ * Copyright 2025-2026 the original author or authors.
*/
package io.modelcontextprotocol.client.transport;
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-
import java.io.IOException;
import java.net.InetSocketAddress;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
-
-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 java.util.function.Predicate;
import com.sun.net.httpserver.HttpServer;
-
+import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler;
+import io.modelcontextprotocol.common.McpTransportContext;
+import org.reactivestreams.Publisher;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpClientTransport;
@@ -28,14 +26,30 @@
import io.modelcontextprotocol.spec.McpTransportException;
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
import io.modelcontextprotocol.spec.ProtocolVersions;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.InstanceOfAssertFactories.type;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
/**
* Tests for error handling changes in HttpClientStreamableHttpTransport. Specifically
* tests the distinction between session-related errors and general transport errors for
* 404 and 400 status codes.
*
* @author Christian Tzolov
+ * @author Daniel Garnier-Moiroux
*/
@Timeout(15)
public class HttpClientStreamableHttpTransportErrorHandlingTest {
@@ -46,11 +60,17 @@ public class HttpClientStreamableHttpTransportErrorHandlingTest {
private HttpServer server;
- private AtomicReference serverResponseStatus = new AtomicReference<>(200);
+ private final AtomicInteger serverResponseStatus = new AtomicInteger(200);
+
+ private final AtomicInteger serverSseResponseStatus = new AtomicInteger(200);
- private AtomicReference currentServerSessionId = new AtomicReference<>(null);
+ private final AtomicReference currentServerSessionId = new AtomicReference<>(null);
- private AtomicReference lastReceivedSessionId = new AtomicReference<>(null);
+ private final AtomicReference lastReceivedSessionId = new AtomicReference<>(null);
+
+ private final AtomicInteger processedMessagesCount = new AtomicInteger(0);
+
+ private final AtomicInteger processedSseConnectCount = new AtomicInteger(0);
private McpClientTransport transport;
@@ -88,6 +108,20 @@ else if ("POST".equals(httpExchange.getRequestMethod())) {
else {
httpExchange.sendResponseHeaders(status, 0);
}
+ processedMessagesCount.incrementAndGet();
+ }
+ else if ("GET".equals(httpExchange.getRequestMethod())) {
+ int status = serverSseResponseStatus.get();
+ if (status == 200) {
+ httpExchange.getResponseHeaders().set("Content-Type", "text/event-stream");
+ httpExchange.sendResponseHeaders(200, 0);
+ String sseData = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{}}\n\n";
+ httpExchange.getResponseBody().write(sseData.getBytes());
+ }
+ else {
+ httpExchange.sendResponseHeaders(status, 0);
+ }
+ processedSseConnectCount.incrementAndGet();
}
httpExchange.close();
});
@@ -103,6 +137,7 @@ void stopServer() {
if (server != null) {
server.stop(0);
}
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
}
/**
@@ -334,6 +369,386 @@ else if (status == 404) {
StepVerifier.create(transport.closeGracefully()).verifyComplete();
}
+ @Nested
+ class AuthorizationError {
+
+ @Nested
+ class SendMessage {
+
+ @ParameterizedTest
+ @ValueSource(ints = { 401, 403 })
+ void invokeHandler(int httpStatus) {
+ serverResponseStatus.set(httpStatus);
+
+ AtomicReference capturedResponseInfo = new AtomicReference<>();
+ AtomicReference capturedContext = new AtomicReference<>();
+
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .authorizationErrorHandler((responseInfo, context) -> {
+ capturedResponseInfo.set(responseInfo);
+ capturedContext.set(context);
+ return Mono.just(false);
+ })
+ .build();
+
+ StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
+ .expectErrorMatches(authorizationError(httpStatus))
+ .verify();
+ assertThat(processedMessagesCount.get()).isEqualTo(1);
+ assertThat(capturedResponseInfo.get()).isNotNull();
+ assertThat(capturedResponseInfo.get().statusCode()).isEqualTo(httpStatus);
+ assertThat(capturedContext.get()).isNotNull();
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void defaultHandler() {
+ serverResponseStatus.set(401);
+
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST).build();
+
+ StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
+ .expectErrorMatches(authorizationError(401))
+ .verify();
+ assertThat(processedMessagesCount.get()).isEqualTo(1);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void retry() {
+ serverResponseStatus.set(401);
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .authorizationErrorHandler((responseInfo, context) -> {
+ serverResponseStatus.set(200);
+ return Mono.just(true);
+ })
+ .build();
+ StepVerifier.create(authTransport.sendMessage(createTestRequestMessage())).verifyComplete();
+ // initial request + retry
+ assertThat(processedMessagesCount.get()).isEqualTo(2);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void retryAtMostOnce() {
+ serverResponseStatus.set(401);
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .authorizationErrorHandler((responseInfo, context) -> Mono.just(true))
+ .build();
+ StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
+ .expectErrorMatches(authorizationError(401))
+ .verify();
+ // initial request + 1 retry (maxRetries default is 1)
+ assertThat(processedMessagesCount.get()).isEqualTo(2);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void customMaxRetries() {
+ serverResponseStatus.set(401);
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .authorizationErrorHandler(new McpHttpClientAuthorizationErrorHandler() {
+ @Override
+ public Publisher handle(HttpResponse.ResponseInfo responseInfo,
+ McpTransportContext context) {
+ return Mono.just(true);
+ }
+
+ @Override
+ public int maxRetries() {
+ return 3;
+ }
+ })
+ .build();
+ StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
+ .expectErrorMatches(authorizationError(401))
+ .verify();
+ // initial request + 3 retries
+ assertThat(processedMessagesCount.get()).isEqualTo(4);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void noRetry() {
+ serverResponseStatus.set(401);
+
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .authorizationErrorHandler((responseInfo, context) -> Mono.just(false))
+ .build();
+
+ StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
+ .expectErrorMatches(authorizationError(401))
+ .verify();
+ assertThat(processedMessagesCount.get()).isEqualTo(1);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void propagateHandlerError() {
+ serverResponseStatus.set(401);
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .authorizationErrorHandler(
+ (responseInfo, context) -> Mono.error(new IllegalStateException("handler error")))
+ .build();
+
+ StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
+ .expectErrorMatches(throwable -> throwable instanceof IllegalStateException
+ && throwable.getMessage().equals("handler error"))
+ .verify();
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void emptyHandler() {
+ serverResponseStatus.set(401);
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .authorizationErrorHandler((responseInfo, context) -> Mono.empty())
+ .build();
+
+ StepVerifier.create(authTransport.sendMessage(createTestRequestMessage()))
+ .expectErrorMatches(authorizationError(401))
+ .verify();
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ }
+
+ @Nested
+ class Connect {
+
+ @ParameterizedTest
+ @ValueSource(ints = { 401, 403 })
+ void invokeHandler(int httpStatus) {
+ serverSseResponseStatus.set(httpStatus);
+ @SuppressWarnings("unchecked")
+ AtomicReference capturedException = new AtomicReference<>();
+
+ AtomicReference capturedResponseInfo = new AtomicReference<>();
+ AtomicReference capturedContext = new AtomicReference<>();
+
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .authorizationErrorHandler((responseInfo, context) -> {
+ capturedResponseInfo.set(responseInfo);
+ capturedContext.set(context);
+ return Mono.just(false);
+ })
+ .openConnectionOnStartup(true)
+ .build();
+ authTransport.setExceptionHandler(capturedException::set);
+
+ var messages = new ArrayList();
+ StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
+ assertThat(messages).isEmpty();
+ assertThat(capturedResponseInfo.get()).isNotNull();
+ assertThat(capturedResponseInfo.get().statusCode()).isEqualTo(httpStatus);
+ assertThat(capturedContext.get()).isNotNull();
+ assertThat(capturedException.get()).hasMessage("Authorization error connecting to SSE stream")
+ .asInstanceOf(type(McpHttpClientTransportAuthorizationException.class))
+ .extracting(McpHttpClientTransportAuthorizationException::getResponseInfo)
+ .extracting(HttpResponse.ResponseInfo::statusCode)
+ .isEqualTo(httpStatus);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void defaultHandler() {
+ serverSseResponseStatus.set(401);
+ AtomicReference capturedException = new AtomicReference<>();
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .openConnectionOnStartup(true)
+ .build();
+ authTransport.setExceptionHandler(capturedException::set);
+
+ StepVerifier.create(authTransport.connect(msg -> msg)).verifyComplete();
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
+ assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void retry() {
+ serverSseResponseStatus.set(401);
+ AtomicReference capturedException = new AtomicReference<>();
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .openConnectionOnStartup(true)
+ .authorizationErrorHandler((responseInfo, context) -> {
+ serverSseResponseStatus.set(200);
+ return Mono.just(true);
+ })
+ .build();
+ authTransport.setExceptionHandler(capturedException::set);
+
+ var messages = new ArrayList();
+ var messageHandlerClosed = new AtomicBoolean(false);
+ StepVerifier
+ .create(authTransport
+ .connect(msg -> msg.doOnNext(messages::add).doFinally(s -> messageHandlerClosed.set(true))))
+ .verifyComplete();
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(messageHandlerClosed).isTrue());
+ assertThat(processedSseConnectCount.get()).isEqualTo(2);
+ assertThat(messages).hasSize(1);
+ assertThat(capturedException.get()).isNull();
+ assertThat(messageHandlerClosed.get()).isTrue();
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void retryAtMostOnce() {
+ serverSseResponseStatus.set(401);
+ AtomicReference capturedException = new AtomicReference<>();
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .openConnectionOnStartup(true)
+ .authorizationErrorHandler((responseInfo, context) -> {
+ return Mono.just(true);
+ })
+ .build();
+ authTransport.setExceptionHandler(capturedException::set);
+
+ var messages = new ArrayList();
+ StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(capturedException.get()).isNotNull());
+ // initial request + 1 retry (maxRetries default is 1)
+ assertThat(processedSseConnectCount.get()).isEqualTo(2);
+ assertThat(messages).isEmpty();
+ assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void customMaxRetries() {
+ serverSseResponseStatus.set(401);
+ AtomicReference capturedException = new AtomicReference<>();
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .openConnectionOnStartup(true)
+ .authorizationErrorHandler(new McpHttpClientAuthorizationErrorHandler() {
+ @Override
+ public Publisher handle(HttpResponse.ResponseInfo responseInfo,
+ McpTransportContext context) {
+ return Mono.just(true);
+ }
+
+ @Override
+ public int maxRetries() {
+ return 3;
+ }
+ })
+ .build();
+ authTransport.setExceptionHandler(capturedException::set);
+
+ var messages = new ArrayList();
+ StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(capturedException.get()).isNotNull());
+ // initial request + 3 retries
+ assertThat(processedSseConnectCount.get()).isEqualTo(4);
+ assertThat(messages).isEmpty();
+ assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void noRetry() {
+ serverSseResponseStatus.set(401);
+ AtomicReference capturedException = new AtomicReference<>();
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .openConnectionOnStartup(true)
+ .authorizationErrorHandler((responseInfo, context) -> {
+ // if there was a retry, the request would succeed.
+ serverSseResponseStatus.set(200);
+ return Mono.just(false);
+ })
+ .build();
+ authTransport.setExceptionHandler(capturedException::set);
+
+ var messages = new ArrayList();
+ StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
+ assertThat(messages).isEmpty();
+ assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void emptyHandler() {
+ serverSseResponseStatus.set(401);
+ AtomicReference capturedException = new AtomicReference<>();
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .openConnectionOnStartup(true)
+ .authorizationErrorHandler((responseInfo, context) -> Mono.empty())
+ .build();
+ authTransport.setExceptionHandler(capturedException::set);
+
+ var messages = new ArrayList();
+ StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
+ assertThat(messages).isEmpty();
+ assertThat(capturedException.get()).isInstanceOf(McpHttpClientTransportAuthorizationException.class);
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ @Test
+ void propagateHandlerError() {
+ serverSseResponseStatus.set(401);
+ AtomicReference capturedException = new AtomicReference<>();
+ var authTransport = HttpClientStreamableHttpTransport.builder(HOST)
+ .openConnectionOnStartup(true)
+ .authorizationErrorHandler(
+ (responseInfo, context) -> Mono.error(new IllegalStateException("handler error")))
+ .build();
+ authTransport.setExceptionHandler(capturedException::set);
+
+ var messages = new ArrayList();
+ StepVerifier.create(authTransport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
+ assertThat(messages).isEmpty();
+ assertThat(capturedException.get()).isInstanceOf(IllegalStateException.class)
+ .hasMessage("handler error");
+
+ StepVerifier.create(authTransport.closeGracefully()).verifyComplete();
+ }
+
+ }
+
+ private static Predicate authorizationError(int httpStatus) {
+ return throwable -> throwable instanceof McpHttpClientTransportAuthorizationException
+ && throwable.getMessage().contains("Authorization error")
+ && ((McpHttpClientTransportAuthorizationException) throwable).getResponseInfo()
+ .statusCode() == httpStatus;
+ }
+
+ }
+
private McpSchema.JSONRPCRequest createTestRequestMessage() {
var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26,
McpSchema.ClientCapabilities.builder().roots(true).build(),
From c4b585795ece2a569c1f93ffbb801b0a90a7f65b Mon Sep 17 00:00:00 2001
From: Christian Tzolov <1351573+tzolov@users.noreply.github.com>
Date: Fri, 13 Mar 2026 12:41:49 +0100
Subject: [PATCH 03/17] fix: prepare POMs for Maven Central release readiness
(#863)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix malformed SCM developerConnection URL (slash → colon) across all modules
- Add mcp-json-jackson3 to mcp-bom dependency management
- Update license URL to HTTPS
- Fix POM's scm definitions
Signed-off-by: Christian Tzolov
Signed-off-by: Christian Tzolov
---------
Signed-off-by: Christian Tzolov
---
.../client-jdk-http-client/pom.xml | 11 +++---
.../client-spring-http-client/pom.xml | 6 +--
conformance-tests/pom.xml | 8 ++--
conformance-tests/server-servlet/pom.xml | 4 +-
mcp-bom/pom.xml | 19 +++++++---
mcp-core/pom.xml | 20 +++++-----
mcp-json-jackson2/pom.xml | 38 ++++++++++---------
mcp-json-jackson3/pom.xml | 38 ++++++++++---------
mcp-test/pom.xml | 8 ++--
mcp/pom.xml | 6 +--
pom.xml | 24 ++++++------
11 files changed, 97 insertions(+), 85 deletions(-)
diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml
index f30361438..54618f15c 100644
--- a/conformance-tests/client-jdk-http-client/pom.xml
+++ b/conformance-tests/client-jdk-http-client/pom.xml
@@ -16,14 +16,14 @@
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
true
-
+
io.modelcontextprotocol.sdk
@@ -57,7 +57,8 @@
- io.modelcontextprotocol.conformance.client.ConformanceJdkClientMcpClient
+
+ io.modelcontextprotocol.conformance.client.ConformanceJdkClientMcpClient
@@ -79,4 +80,4 @@
-
+
\ No newline at end of file
diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml
index 46dae68ef..90ed576cf 100644
--- a/conformance-tests/client-spring-http-client/pom.xml
+++ b/conformance-tests/client-spring-http-client/pom.xml
@@ -16,8 +16,8 @@
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
@@ -106,4 +106,4 @@
-
+
\ No newline at end of file
diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml
index d1bef2a24..7329fe849 100644
--- a/conformance-tests/pom.xml
+++ b/conformance-tests/pom.xml
@@ -16,18 +16,18 @@
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
true
-
+
client-jdk-http-client
client-spring-http-client
server-servlet
-
+
\ No newline at end of file
diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml
index 66acea835..289599a5e 100644
--- a/conformance-tests/server-servlet/pom.xml
+++ b/conformance-tests/server-servlet/pom.xml
@@ -16,8 +16,8 @@
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml
index fb6f3a32a..aa6cc7914 100644
--- a/mcp-bom/pom.xml
+++ b/mcp-bom/pom.xml
@@ -16,13 +16,13 @@
Java SDK MCP BOM
Java SDK MCP Bill of Materials
- https://github.com/modelcontextprotocol/java-sdk
+ https://github.com/modelcontextprotocol/java-sdk
-
- https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
-
+
+ https://github.com/modelcontextprotocol/java-sdk
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
+
@@ -47,6 +47,13 @@
${project.version}
+
+
+ io.modelcontextprotocol.sdk
+ mcp-json-jackson3
+ ${project.version}
+
+
io.modelcontextprotocol.sdk
diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml
index 4de0fba2b..3f7fa0b83 100644
--- a/mcp-core/pom.xml
+++ b/mcp-core/pom.xml
@@ -16,8 +16,8 @@
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
@@ -164,14 +164,14 @@
test
-
-
- com.google.code.gson
- gson
- 2.10.1
- test
-
+
+
+ com.google.code.gson
+ gson
+ 2.10.1
+ test
+
-
+
\ No newline at end of file
diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml
index f25877cd3..d36762aa0 100644
--- a/mcp-json-jackson2/pom.xml
+++ b/mcp-json-jackson2/pom.xml
@@ -13,11 +13,13 @@
Java MCP SDK JSON Jackson 2
Java MCP SDK JSON implementation based on Jackson 2
https://github.com/modelcontextprotocol/java-sdk
+
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
+
@@ -62,21 +64,21 @@
-
- com.fasterxml.jackson.core
- jackson-databind
- ${jackson2.version}
-
-
- io.modelcontextprotocol.sdk
- mcp-core
- 1.1.0-SNAPSHOT
-
-
- com.networknt
- json-schema-validator
- ${json-schema-validator-jackson2.version}
-
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson2.version}
+
+
+ io.modelcontextprotocol.sdk
+ mcp-core
+ 1.1.0-SNAPSHOT
+
+
+ com.networknt
+ json-schema-validator
+ ${json-schema-validator-jackson2.version}
+
org.assertj
@@ -104,4 +106,4 @@
-
+
\ No newline at end of file
diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml
index 99baf14e1..cd6ecaa3a 100644
--- a/mcp-json-jackson3/pom.xml
+++ b/mcp-json-jackson3/pom.xml
@@ -13,11 +13,13 @@
Java MCP SDK JSON Jackson 3
Java MCP SDK JSON implementation based on Jackson 3
https://github.com/modelcontextprotocol/java-sdk
+
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
+
@@ -61,21 +63,21 @@
-
- io.modelcontextprotocol.sdk
- mcp-core
- 1.1.0-SNAPSHOT
-
-
- tools.jackson.core
- jackson-databind
- ${jackson3.version}
-
-
- com.networknt
- json-schema-validator
- ${json-schema-validator-jackson3.version}
-
+
+ io.modelcontextprotocol.sdk
+ mcp-core
+ 1.1.0-SNAPSHOT
+
+
+ tools.jackson.core
+ jackson-databind
+ ${jackson3.version}
+
+
+ com.networknt
+ json-schema-validator
+ ${json-schema-validator-jackson3.version}
+
org.assertj
@@ -103,4 +105,4 @@
-
+
\ No newline at end of file
diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml
index 531c0bbc5..53fb84941 100644
--- a/mcp-test/pom.xml
+++ b/mcp-test/pom.xml
@@ -1,7 +1,7 @@
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4.0.0
io.modelcontextprotocol.sdk
@@ -16,8 +16,8 @@
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
diff --git a/mcp/pom.xml b/mcp/pom.xml
index 937974228..5dc80163d 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -16,8 +16,8 @@
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
@@ -36,4 +36,4 @@
-
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index b1eedd38e..cdbeb25f2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -13,8 +13,8 @@
https://github.com/modelcontextprotocol/java-sdk
- git://github.com/modelcontextprotocol/java-sdk.git
- git@github.com/modelcontextprotocol/java-sdk.git
+ scm:git:git://github.com/modelcontextprotocol/java-sdk.git
+ scm:git:ssh://git@github.com/modelcontextprotocol/java-sdk.git
Java SDK MCP Parent
@@ -29,7 +29,7 @@
MIT License
- http://www.opensource.org/licenses/mit-license.php
+ https://www.opensource.org/licenses/mit-license.php
@@ -57,7 +57,7 @@
17
17
17
-
+
3.27.6
6.0.2
@@ -105,11 +105,11 @@
mcp-bom
mcp
- mcp-core
- mcp-json-jackson2
- mcp-json-jackson3
+ mcp-core
+ mcp-json-jackson2
+ mcp-json-jackson3
mcp-test
- conformance-tests
+ conformance-tests
@@ -329,9 +329,9 @@
true
central
-
- mcp-parent,conformance-tests,client-jdk-http-client,client-spring-http-client,server-servlet
-
+
+ mcp-parent,conformance-tests,client-jdk-http-client,client-spring-http-client,server-servlet
+
true
@@ -387,4 +387,4 @@
-
+
\ No newline at end of file
From cbb235fd32ea29c93f07642a7a6f83893672c8ec Mon Sep 17 00:00:00 2001
From: Christian Tzolov
Date: Fri, 13 Mar 2026 13:31:03 +0100
Subject: [PATCH 04/17] Next development version
Signed-off-by: Christian Tzolov
---
conformance-tests/client-jdk-http-client/pom.xml | 4 ++--
conformance-tests/client-spring-http-client/pom.xml | 2 +-
conformance-tests/pom.xml | 2 +-
conformance-tests/server-servlet/pom.xml | 4 ++--
mcp-bom/pom.xml | 2 +-
mcp-core/pom.xml | 2 +-
mcp-json-jackson2/pom.xml | 4 ++--
mcp-json-jackson3/pom.xml | 4 ++--
mcp-test/pom.xml | 8 ++++----
mcp/pom.xml | 6 +++---
pom.xml | 2 +-
11 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/conformance-tests/client-jdk-http-client/pom.xml b/conformance-tests/client-jdk-http-client/pom.xml
index 54618f15c..f939cfa6c 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.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
client-jdk-http-client
jar
@@ -28,7 +28,7 @@
io.modelcontextprotocol.sdk
mcp
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml
index 90ed576cf..06b53887d 100644
--- a/conformance-tests/client-spring-http-client/pom.xml
+++ b/conformance-tests/client-spring-http-client/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
conformance-tests
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
client-spring-http-client
jar
diff --git a/conformance-tests/pom.xml b/conformance-tests/pom.xml
index 7329fe849..88ab7c4b0 100644
--- a/conformance-tests/pom.xml
+++ b/conformance-tests/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
conformance-tests
pom
diff --git a/conformance-tests/server-servlet/pom.xml b/conformance-tests/server-servlet/pom.xml
index 289599a5e..a80c7c4ec 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.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
server-servlet
jar
@@ -28,7 +28,7 @@
io.modelcontextprotocol.sdk
mcp
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
diff --git a/mcp-bom/pom.xml b/mcp-bom/pom.xml
index aa6cc7914..303520517 100644
--- a/mcp-bom/pom.xml
+++ b/mcp-bom/pom.xml
@@ -7,7 +7,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
mcp-bom
diff --git a/mcp-core/pom.xml b/mcp-core/pom.xml
index 3f7fa0b83..d622df0d1 100644
--- a/mcp-core/pom.xml
+++ b/mcp-core/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
mcp-core
jar
diff --git a/mcp-json-jackson2/pom.xml b/mcp-json-jackson2/pom.xml
index d36762aa0..5dd9a5ac1 100644
--- a/mcp-json-jackson2/pom.xml
+++ b/mcp-json-jackson2/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
mcp-json-jackson2
jar
@@ -72,7 +72,7 @@
io.modelcontextprotocol.sdk
mcp-core
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
com.networknt
diff --git a/mcp-json-jackson3/pom.xml b/mcp-json-jackson3/pom.xml
index cd6ecaa3a..2afd474f6 100644
--- a/mcp-json-jackson3/pom.xml
+++ b/mcp-json-jackson3/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
mcp-json-jackson3
jar
@@ -66,7 +66,7 @@
io.modelcontextprotocol.sdk
mcp-core
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
tools.jackson.core
diff --git a/mcp-test/pom.xml b/mcp-test/pom.xml
index 53fb84941..45e74717c 100644
--- a/mcp-test/pom.xml
+++ b/mcp-test/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
mcp-test
jar
@@ -24,7 +24,7 @@
io.modelcontextprotocol.sdk
mcp-core
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
@@ -159,7 +159,7 @@
io.modelcontextprotocol.sdk
mcp-json-jackson3
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
test
@@ -170,7 +170,7 @@
io.modelcontextprotocol.sdk
mcp-json-jackson2
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
test
diff --git a/mcp/pom.xml b/mcp/pom.xml
index 5dc80163d..16fca0ba4 100644
--- a/mcp/pom.xml
+++ b/mcp/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
mcp
jar
@@ -25,13 +25,13 @@
io.modelcontextprotocol.sdk
mcp-json-jackson3
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
io.modelcontextprotocol.sdk
mcp-core
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
diff --git a/pom.xml b/pom.xml
index cdbeb25f2..d738e26e6 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
io.modelcontextprotocol.sdk
mcp-parent
- 1.1.0-SNAPSHOT
+ 2.0.0-SNAPSHOT
pom
https://github.com/modelcontextprotocol/java-sdk
From 685b1899f9383787fbcaa80a59620c6bc073070c Mon Sep 17 00:00:00 2001
From: Daniel Garnier-Moiroux
Date: Fri, 27 Mar 2026 19:19:47 +0100
Subject: [PATCH 05/17] Merge commit from fork
---
.../server/transport/HttpServletSseServerTransportProvider.java | 1 -
.../transport/HttpServletStreamableServerTransportProvider.java | 2 --
2 files changed, 3 deletions(-)
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
index d3648a06f..0fb2fa778 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletSseServerTransportProvider.java
@@ -286,7 +286,6 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
response.setCharacterEncoding(UTF_8);
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
- response.setHeader("Access-Control-Allow-Origin", "*");
String sessionId = UUID.randomUUID().toString();
AsyncContext asyncContext = request.startAsync();
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
index 95edb63a0..fe38b2589 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/server/transport/HttpServletStreamableServerTransportProvider.java
@@ -315,7 +315,6 @@ protected void doGet(HttpServletRequest request, HttpServletResponse response)
response.setCharacterEncoding(UTF_8);
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
- response.setHeader("Access-Control-Allow-Origin", "*");
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0);
@@ -522,7 +521,6 @@ else if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
response.setCharacterEncoding(UTF_8);
response.setHeader("Cache-Control", "no-cache");
response.setHeader("Connection", "keep-alive");
- response.setHeader("Access-Control-Allow-Origin", "*");
AsyncContext asyncContext = request.startAsync();
asyncContext.setTimeout(0);
From 3a7818201e68353ee322e88748425de5643532b4 Mon Sep 17 00:00:00 2001
From: Sergei Semenov
Date: Wed, 1 Apr 2026 08:55:33 -0700
Subject: [PATCH 06/17] Fixed URL links from README.md to 'Java Dependencies',
'Java MCP Client', 'Java MCP Server' pages (#874)
---
README.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 34133a796..a1206adc5 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,9 @@ For comprehensive guides and SDK API documentation
- [Features](https://modelcontextprotocol.github.io/java-sdk/#features) - Overview the features provided by the Java MCP SDK
- [Architecture](https://modelcontextprotocol.github.io/java-sdk/#architecture) - Java MCP SDK architecture overview.
-- [Java Dependencies / BOM](https://modelcontextprotocol.github.io/java-sdk/quickstart/#dependencies) - Java dependencies and BOM.
-- [Java MCP Client](https://modelcontextprotocol.github.io/java-sdk/client/) - Learn how to use the MCP client to interact with MCP servers.
-- [Java MCP Server](https://modelcontextprotocol.github.io/java-sdk/server/) - Learn how to implement and configure a MCP servers.
+- [Java Dependencies / BOM](https://java.sdk.modelcontextprotocol.io/latest/quickstart/#dependencies) - Java dependencies and BOM.
+- [Java MCP Client](https://java.sdk.modelcontextprotocol.io/latest/client/) - Learn how to use the MCP client to interact with MCP servers.
+- [Java MCP Server](https://java.sdk.modelcontextprotocol.io/latest/server/) - Learn how to implement and configure a MCP servers.
#### Spring AI MCP documentation
[Spring AI MCP](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-overview.html) extends the MCP Java SDK with Spring Boot integration, providing both [client](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-client-boot-starter-docs.html) and [server](https://docs.spring.io/spring-ai/reference/2.0-SNAPSHOT/api/mcp/mcp-server-boot-starter-docs.html) starters.
From b6eb672c190a08f74b95be3f3b3932ba42ef40c6 Mon Sep 17 00:00:00 2001
From: matteoroxis
Date: Mon, 30 Mar 2026 16:58:58 +0200
Subject: [PATCH 07/17] docs: add conformance summary to README
Signed-off-by: Daniel Garnier-Moiroux
---
README.md | 35 +++++++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
diff --git a/README.md b/README.md
index a1206adc5..4873876a6 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,41 @@ To run the tests you have to pre-install `Docker` and `npx`.
```bash
./mvnw test
```
+### Conformance Tests
+
+The SDK is validated against the [MCP conformance test suite](https://github.com/modelcontextprotocol/conformance) at 0.1.15 version.
+Full details and instructions are in [`conformance-tests/VALIDATION_RESULTS.md`](conformance-tests/VALIDATION_RESULTS.md).
+
+**Latest results:**
+
+| Suite | Result |
+|---------------|-----------------------------------------------------|
+| Server | ✅ 40/40 passed (100%) |
+| Client | 🟡 3/4 scenarios, 9/10 checks passed |
+| Auth (Spring) | 🟡 12/14 scenarios fully passing (98.9% checks) |
+
+To run the conformance tests locally you need `npx` installed.
+
+```bash
+# Server conformance
+./mvnw compile -pl conformance-tests/server-servlet -am exec:java
+npx @modelcontextprotocol/conformance server --url http://localhost:8080/mcp --suite active
+
+# Client conformance
+./mvnw clean package -DskipTests -pl conformance-tests/client-jdk-http-client -am
+for scenario in initialize tools_call elicitation-sep1034-client-defaults sse-retry; do
+ npx @modelcontextprotocol/conformance client \
+ --command "java -jar conformance-tests/client-jdk-http-client/target/client-jdk-http-client-2.0.0-SNAPSHOT.jar" \
+ --scenario $scenario
+done
+
+# Auth conformance (Spring HTTP Client)
+./mvnw clean package -DskipTests -pl conformance-tests/client-spring-http-client -am
+npx @modelcontextprotocol/conformance@0.1.15 client \
+ --spec-version 2025-11-25 \
+ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar" \
+ --suite auth
+```
## Contributing
From 22e7bd4b56b8282d7da3f3ef0460de0dc90e99aa Mon Sep 17 00:00:00 2001
From: Daniel Garnier-Moiroux
Date: Tue, 31 Mar 2026 16:32:11 +0200
Subject: [PATCH 08/17] conformance: update to mcp-security 0.1.5, pass
scope-step-up
Signed-off-by: Daniel Garnier-Moiroux
---
conformance-tests/VALIDATION_RESULTS.md | 37 +++++++++----------
.../client-spring-http-client/README.md | 12 +++---
.../client-spring-http-client/pom.xml | 14 +++++--
.../ConformanceSpringClientApplication.java | 14 ++++++-
.../client/McpClientController.java | 12 ++++++
.../configuration/DefaultConfiguration.java | 12 +++---
.../client/scenario/DefaultScenario.java | 27 ++++++++++----
.../scenario/PreRegistrationScenario.java | 2 +-
conformance-tests/conformance-baseline.yml | 2 -
9 files changed, 86 insertions(+), 46 deletions(-)
diff --git a/conformance-tests/VALIDATION_RESULTS.md b/conformance-tests/VALIDATION_RESULTS.md
index 8edc7ad71..e4ce396bc 100644
--- a/conformance-tests/VALIDATION_RESULTS.md
+++ b/conformance-tests/VALIDATION_RESULTS.md
@@ -4,7 +4,7 @@
**Server Tests:** 40/40 passed (100%)
**Client Tests:** 3/4 scenarios passed (9/10 checks passed)
-**Auth Tests:** 12/14 scenarios fully passing (178 passed, 1 failed, 1 warning, 85.7% scenarios, 98.9% checks)
+**Auth Tests:** 14/15 scenarios fully passing (196 passed, 0 failed, 1 warning, 93.3% scenarios, 99.5% checks)
## Server Test Results
@@ -37,35 +37,35 @@
## Auth Test Results (Spring HTTP Client)
-**Status: 178 passed, 1 failed, 1 warning across 14 scenarios**
+**Status: 196 passed, 0 failed, 1 warning across 15 scenarios**
Uses the `client-spring-http-client` module with Spring Security OAuth2 and the [mcp-client-security](https://github.com/springaicommunity/mcp-client-security) library.
-### Fully Passing (12/14 scenarios)
+### Fully Passing (14/15 scenarios)
-- **auth/metadata-default (12/12):** Default metadata discovery
-- **auth/metadata-var1 (12/12):** Metadata discovery variant 1
-- **auth/metadata-var2 (12/12):** Metadata discovery variant 2
-- **auth/metadata-var3 (12/12):** Metadata discovery variant 3
-- **auth/scope-from-www-authenticate (13/13):** Scope extraction from WWW-Authenticate header
-- **auth/scope-from-scopes-supported (13/13):** Scope extraction from scopes_supported
-- **auth/scope-omitted-when-undefined (13/13):** Scope omitted when not defined
+- **auth/metadata-default (13/13):** Default metadata discovery
+- **auth/metadata-var1 (13/13):** Metadata discovery variant 1
+- **auth/metadata-var2 (13/13):** Metadata discovery variant 2
+- **auth/metadata-var3 (13/13):** Metadata discovery variant 3
+- **auth/scope-from-www-authenticate (14/14):** Scope extraction from WWW-Authenticate header
+- **auth/scope-from-scopes-supported (14/14):** Scope extraction from scopes_supported
+- **auth/scope-omitted-when-undefined (14/14):** Scope omitted when not defined
+- **auth/scope-step-up (16/16):** Scope step-up challenge
- **auth/scope-retry-limit (11/11):** Scope retry limit handling
-- **auth/token-endpoint-auth-basic (17/17):** Token endpoint with HTTP Basic auth
-- **auth/token-endpoint-auth-post (17/17):** Token endpoint with POST body auth
-- **auth/token-endpoint-auth-none (17/17):** Token endpoint with no client auth
+- **auth/token-endpoint-auth-basic (18/18):** Token endpoint with HTTP Basic auth
+- **auth/token-endpoint-auth-post (18/18):** Token endpoint with POST body auth
+- **auth/token-endpoint-auth-none (18/18):** Token endpoint with no client auth
+- **auth/resource-mismatch (2/2):** Resource mismatch handling
- **auth/pre-registration (6/6):** Pre-registered client credentials flow
-### Partially Passing (2/14 scenarios)
+### Partially Passing (1/15 scenarios)
-- **auth/basic-cimd (12/12 + 1 warning):** Basic Client-Initiated Metadata Discovery — all checks pass, minor warning
-- **auth/scope-step-up (11/12):** Scope step-up challenge — 1 failure, client does not fully handle scope escalation after initial authorization
+- **auth/basic-cimd (13/13 + 1 warning):** Basic Client-Initiated Metadata Discovery — all checks pass, minor warning
## Known Limitations
1. **Client SSE Retry:** Client doesn't parse or respect the `retry:` field, reconnects immediately, and doesn't send Last-Event-ID header
-2. **Auth Scope Step-Up:** Client does not fully handle scope step-up challenges where the server requests additional scopes after initial authorization
-3. **Auth Basic CIMD:** Minor conformance warning in the basic Client-Initiated Metadata Discovery flow
+2. **Auth Basic CIMD:** Minor conformance warning in the basic Client-Initiated Metadata Discovery flow
## Running Tests
@@ -113,4 +113,3 @@ npx @modelcontextprotocol/conformance@0.1.15 client \
### High Priority
1. Fix client SSE retry field handling in `HttpClientStreamableHttpTransport`
2. Implement CIMD
-3. Implement scope step up
diff --git a/conformance-tests/client-spring-http-client/README.md b/conformance-tests/client-spring-http-client/README.md
index afbf64773..e5ed016c3 100644
--- a/conformance-tests/client-spring-http-client/README.md
+++ b/conformance-tests/client-spring-http-client/README.md
@@ -26,7 +26,7 @@ Test with @modelcontextprotocol/conformance@0.1.15.
| auth/scope-from-www-authenticate | ✅ Pass | 13/13 |
| auth/scope-from-scopes-supported | ✅ Pass | 13/13 |
| auth/scope-omitted-when-undefined | ✅ Pass | 13/13 |
-| auth/scope-step-up | ❌ Fail | 11/12 (1 failed) |
+| auth/scope-step-up | ✅ Pass | 12/12 |
| auth/scope-retry-limit | ✅ Pass | 11/11 |
| auth/token-endpoint-auth-basic | ✅ Pass | 17/17 |
| auth/token-endpoint-auth-post | ✅ Pass | 17/17 |
@@ -67,7 +67,7 @@ cd conformance-tests/client-spring-http-client
This creates an executable JAR at:
```
-target/client-spring-http-client-1.1.0-SNAPSHOT.jar
+target/client-spring-http-client-2.0.0-SNAPSHOT.jar
```
## Running Tests
@@ -79,7 +79,7 @@ Run the full auth suite:
```bash
npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \
- --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar" \
--suite auth
```
@@ -88,7 +88,7 @@ Run a single scenario:
```bash
npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \
- --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar" \
--scenario auth/metadata-default
```
@@ -97,7 +97,7 @@ Run with verbose output:
```bash
npx @modelcontextprotocol/conformance@0.1.15 client \
--spec-version 2025-11-25 \
- --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar" \
+ --command "java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar" \
--scenario auth/metadata-default \
--verbose
```
@@ -108,7 +108,7 @@ You can also run the client manually if you have a test server:
```bash
export MCP_CONFORMANCE_SCENARIO=auth/metadata-default
-java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-1.1.0-SNAPSHOT.jar http://localhost:3000/mcp
+java -jar conformance-tests/client-spring-http-client/target/client-spring-http-client-2.0.0-SNAPSHOT.jar http://localhost:3000/mcp
```
## Known Issues
diff --git a/conformance-tests/client-spring-http-client/pom.xml b/conformance-tests/client-spring-http-client/pom.xml
index 06b53887d..44aa7f925 100644
--- a/conformance-tests/client-spring-http-client/pom.xml
+++ b/conformance-tests/client-spring-http-client/pom.xml
@@ -22,8 +22,9 @@
17
- 4.0.2
- 2.0.0-M2
+ 4.0.5
+ 2.0.0-M4
+ 0.1.5
true
@@ -64,7 +65,12 @@
org.springaicommunity
mcp-client-security
- 0.1.2
+ ${spring-ai-mcp-security.version}
+
+
+ io.modelcontextprotocol.sdk
+ mcp-core
+ ${project.version}
@@ -106,4 +112,4 @@
-
\ No newline at end of file
+
diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java
index 00582c9f2..63c3601f0 100644
--- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java
+++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/ConformanceSpringClientApplication.java
@@ -8,8 +8,11 @@
import io.modelcontextprotocol.conformance.client.scenario.Scenario;
import org.springaicommunity.mcp.security.client.sync.oauth2.metadata.McpMetadataDiscoveryService;
+import org.springaicommunity.mcp.security.client.sync.oauth2.registration.DefaultMcpOAuth2ClientManager;
import org.springaicommunity.mcp.security.client.sync.oauth2.registration.DynamicClientRegistrationService;
import org.springaicommunity.mcp.security.client.sync.oauth2.registration.InMemoryMcpClientRegistrationRepository;
+import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository;
+import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2ClientManager;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
@@ -49,8 +52,15 @@ McpMetadataDiscoveryService discovery() {
}
@Bean
- InMemoryMcpClientRegistrationRepository clientRegistrationRepository(McpMetadataDiscoveryService discovery) {
- return new InMemoryMcpClientRegistrationRepository(new DynamicClientRegistrationService(), discovery);
+ McpClientRegistrationRepository clientRegistrationRepository() {
+ return new InMemoryMcpClientRegistrationRepository();
+ }
+
+ @Bean
+ McpOAuth2ClientManager mcpOAuth2ClientManager(McpClientRegistrationRepository mcpClientRegistrationRepository,
+ McpMetadataDiscoveryService mcpMetadataDiscoveryService) {
+ return new DefaultMcpOAuth2ClientManager(mcpClientRegistrationRepository,
+ new DynamicClientRegistrationService(), mcpMetadataDiscoveryService);
}
@Bean
diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java
index e02cfd416..1b1910298 100644
--- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java
+++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/McpClientController.java
@@ -5,6 +5,7 @@
package io.modelcontextprotocol.conformance.client;
import io.modelcontextprotocol.conformance.client.scenario.Scenario;
+import io.modelcontextprotocol.spec.McpSchema;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -27,4 +28,15 @@ public String execute() {
return "OK";
}
+ @GetMapping("/tools-list")
+ public String toolsList() {
+ return "OK";
+ }
+
+ @GetMapping("/tools-call")
+ public String toolsCall() {
+ this.scenario.getMcpClient().callTool(McpSchema.CallToolRequest.builder().name("test-tool").build());
+ return "OK";
+ }
+
}
diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java
index 12a9c4a5c..febd0f461 100644
--- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java
+++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/configuration/DefaultConfiguration.java
@@ -8,15 +8,16 @@
import io.modelcontextprotocol.conformance.client.scenario.DefaultScenario;
import org.springaicommunity.mcp.security.client.sync.config.McpClientOAuth2Configurer;
import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository;
+import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2ClientManager;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
+import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.web.SecurityFilterChain;
-import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID;
@Configuration
@ConditionalOnExpression("#{environment['MCP_CONFORMANCE_SCENARIO'] != 'auth/pre-registration'}")
@@ -25,15 +26,16 @@ public class DefaultConfiguration {
@Bean
DefaultScenario defaultScenario(McpClientRegistrationRepository clientRegistrationRepository,
ServletWebServerApplicationContext serverCtx,
- OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) {
- return new DefaultScenario(clientRegistrationRepository, serverCtx, oAuth2AuthorizedClientRepository);
+ OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository,
+ McpOAuth2ClientManager mcpOAuth2ClientManager) {
+ return new DefaultScenario(clientRegistrationRepository, serverCtx, oAuth2AuthorizedClientRepository,
+ mcpOAuth2ClientManager);
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, ConformanceSpringClientApplication.ServerUrl serverUrl) {
return http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll())
- .with(new McpClientOAuth2Configurer(),
- mcp -> mcp.registerMcpOAuth2Client(REGISTRATION_ID, serverUrl.value()))
+ .with(new McpClientOAuth2Configurer(), Customizer.withDefaults())
.build();
}
diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java
index 907cea10d..b1fb78a14 100644
--- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java
+++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/DefaultScenario.java
@@ -17,15 +17,16 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springaicommunity.mcp.security.client.sync.AuthenticationMcpTransportContextProvider;
-import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2AuthorizationCodeSyncHttpRequestCustomizer;
+import org.springaicommunity.mcp.security.client.sync.oauth2.http.client.OAuth2HttpClientTransportCustomizer;
import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpClientRegistrationRepository;
+import org.springaicommunity.mcp.security.client.sync.oauth2.registration.McpOAuth2ClientManager;
import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.web.client.RestClient;
-import static io.modelcontextprotocol.conformance.client.ConformanceSpringClientApplication.REGISTRATION_ID;
+import org.springframework.web.util.UriComponentsBuilder;
public class DefaultScenario implements Scenario {
@@ -35,12 +36,19 @@ public class DefaultScenario implements Scenario {
private final DefaultOAuth2AuthorizedClientManager authorizedClientManager;
+ private final McpClientRegistrationRepository clientRegistrationRepository;
+
+ private final McpOAuth2ClientManager mcpOAuth2ClientManager;
+
private McpSyncClient client;
public DefaultScenario(McpClientRegistrationRepository clientRegistrationRepository,
ServletWebServerApplicationContext serverCtx,
- OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository) {
+ OAuth2AuthorizedClientRepository oAuth2AuthorizedClientRepository,
+ McpOAuth2ClientManager mcpOAuth2ClientManager) {
this.serverCtx = serverCtx;
+ this.clientRegistrationRepository = clientRegistrationRepository;
+ this.mcpOAuth2ClientManager = mcpOAuth2ClientManager;
this.authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository,
oAuth2AuthorizedClientRepository);
}
@@ -51,10 +59,13 @@ public void execute(String serverUrl) {
var testServerUrl = "http://localhost:" + serverCtx.getWebServer().getPort();
var testClient = buildTestClient(testServerUrl);
- var customizer = new OAuth2AuthorizationCodeSyncHttpRequestCustomizer(authorizedClientManager, REGISTRATION_ID);
- HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport.builder(serverUrl)
- .httpRequestCustomizer(customizer)
- .build();
+ var customizer = new OAuth2HttpClientTransportCustomizer(authorizedClientManager, clientRegistrationRepository,
+ mcpOAuth2ClientManager);
+ var baseUri = UriComponentsBuilder.fromUriString(serverUrl).replacePath(null).toUriString();
+ var path = UriComponentsBuilder.fromUriString(serverUrl).build().getPath();
+ var transportBuilder = HttpClientStreamableHttpTransport.builder(baseUri).endpoint(path);
+ customizer.customize("default-transport", transportBuilder);
+ HttpClientStreamableHttpTransport transport = transportBuilder.build();
this.client = McpClient.sync(transport)
.transportContextProvider(new AuthenticationMcpTransportContextProvider())
@@ -64,6 +75,8 @@ public void execute(String serverUrl) {
try {
testClient.get().uri("/initialize-mcp-client").retrieve().toBodilessEntity();
+ testClient.get().uri("/tools-list").retrieve().toBodilessEntity();
+ testClient.get().uri("/tools-call").retrieve().toBodilessEntity();
}
finally {
// Close the client (which will close the transport)
diff --git a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java
index 8e6bbe228..accb7862a 100644
--- a/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java
+++ b/conformance-tests/client-spring-http-client/src/main/java/io/modelcontextprotocol/conformance/client/scenario/PreRegistrationScenario.java
@@ -87,7 +87,7 @@ private void setClientRegistration(String mcpServerUrl, PreRegistrationContext o
.clientSecret(oauthCredentials.clientSecret())
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.build();
- clientRegistrationRepository.addPreRegisteredClient(registration,
+ clientRegistrationRepository.addClientRegistration(registration,
metadata.protectedResourceMetadata().resource());
}
diff --git a/conformance-tests/conformance-baseline.yml b/conformance-tests/conformance-baseline.yml
index d2990c155..37cdb3110 100644
--- a/conformance-tests/conformance-baseline.yml
+++ b/conformance-tests/conformance-baseline.yml
@@ -9,5 +9,3 @@ client:
- sse-retry
# CIMD not implemented yet
- auth/basic-cimd
- # Scope step up beyond initial authorization request not implemented
- - auth/scope-step-up
From cd2c21c1c82a0ca45fd50e798464566aef9267e3 Mon Sep 17 00:00:00 2001
From: Radesh Govind
Date: Thu, 2 Apr 2026 10:07:04 +0100
Subject: [PATCH 09/17] docs: document best practice for handling argument
errors in MCP tools (#891)
Resolves #356
Clarify the two-tier error model:
- Recoverable tool errors: use CallToolResult with isError(true)
- Protocol-level errors: throw McpError / let exceptions propagate as JSON-RPC errors
Signed-off-by: Daniel Garnier-Moiroux
---
docs/server.md | 39 +++++++++++++++++++++++++++++++++++++++
1 file changed, 39 insertions(+)
diff --git a/docs/server.md b/docs/server.md
index f9f3aa683..378de6975 100644
--- a/docs/server.md
+++ b/docs/server.md
@@ -795,3 +795,42 @@ Supported logging levels (in order of increasing severity): DEBUG (0), INFO (1),
## Error Handling
The SDK provides comprehensive error handling through the McpError class, covering protocol compatibility, transport communication, JSON-RPC messaging, tool execution, resource management, prompt handling, timeouts, and connection issues. This unified error handling approach ensures consistent and reliable error management across both synchronous and asynchronous operations.
+
+### Error Handling in Tool Implementations
+
+#### Two Tiers of Errors
+
+MCP distinguishes between two categories of errors in tool execution:
+
+**1. Tool-Level Errors (Recoverable by the LLM)**
+
+Use `CallToolResult` with `isError(true)` for validation failures, missing arguments, or domain errors the LLM can act on and retry.
+
+```java
+// Example: Domain validation failure (e.g., invalid email format)
+if (!emailAddress.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
+ return CallToolResult.builder()
+ .content(List.of(new McpSchema.TextContent("Invalid argument: 'email' must be a valid email address.")))
+ .isError(true)
+ .build();
+}
+```
+
+The LLM receives this as part of the normal tool response and can self-correct in a subsequent interaction.
+
+**2. Protocol-Level Errors (Unrecoverable)**
+
+Uncaught exceptions from a tool handler are mapped to a JSON-RPC error response. Use this only for truly unexpected failures (e.g., infrastructure errors such as DB timeout), not for input validation.
+
+```java
+// This propagates as a JSON-RPC error — use sparingly
+throw new McpError(McpSchema.ErrorCodes.INTERNAL_ERROR, "Unexpected failure");
+```
+
+#### Decision Guide
+
+| Situation | Approach |
+|------------------------------------|---------------------------------------|
+| Domain validation failure | `CallToolResult` with `isError=true` |
+| Infrastructure / unexpected error | Throw `McpError` or let it propagate |
+| Partial success with a warning | `CallToolResult` with warning in text |
From 301dbe46ce4af39952d03b5acae61ea5d8bd008c Mon Sep 17 00:00:00 2001
From: "300:29:1" <96401828+gyeo009@users.noreply.github.com>
Date: Thu, 2 Apr 2026 18:38:53 +0900
Subject: [PATCH 10/17] Deprecate Builder.customizeRequest() in favor of
httpRequestCustomizer() (#791)
Deprecate Builder.customizeRequest() in HttpClientSseClientTransport and HttpClientStreamableHttpTransport
customizeRequest() executes its consumer once at build time, freezing
headers into the shared requestBuilder. This silently breaks OAuth
token refresh scenarios where the Authorization header needs to be
updated after the transport is built.
Add @Deprecated and update Javadoc to clarify the build-time-only
semantics and guide users toward httpRequestCustomizer() or
asyncHttpRequestCustomizer() which run on every request.
Closes #788
Signed-off-by: Daniel Garnier-Moiroux
---
.../client/transport/HttpClientSseClientTransport.java | 9 ++++++++-
.../transport/HttpClientStreamableHttpTransport.java | 9 ++++++++-
2 files changed, 16 insertions(+), 2 deletions(-)
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..48bd2f416 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
@@ -241,10 +241,17 @@ public Builder requestBuilder(HttpRequest.Builder requestBuilder) {
}
/**
- * Customizes the HTTP client builder.
+ * Applies the given consumer to the shared {@link HttpRequest.Builder} once,
+ * at build time. Any headers set here are frozen into the template and
+ * cannot be updated after the transport is built.
* @param requestCustomizer the consumer to customize the HTTP request builder
* @return this builder
+ * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable
+ * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}
+ * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for
+ * dynamic per-request customization.
*/
+ @Deprecated
public Builder customizeRequest(final Consumer requestCustomizer) {
Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
requestCustomizer.accept(requestBuilder);
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
index 57a27a3fd..9e9b7f923 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
@@ -738,10 +738,17 @@ public Builder requestBuilder(HttpRequest.Builder requestBuilder) {
}
/**
- * Customizes the HTTP client builder.
+ * Applies the given consumer to the shared {@link HttpRequest.Builder} once,
+ * at build time. Any headers set here are frozen into the template and
+ * cannot be updated after the transport is built.
* @param requestCustomizer the consumer to customize the HTTP request builder
* @return this builder
+ * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable
+ * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}
+ * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for
+ * dynamic per-request customization.
*/
+ @Deprecated
public Builder customizeRequest(final Consumer requestCustomizer) {
Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
requestCustomizer.accept(requestBuilder);
From 8c7774ad5fa2e6846946be4ba89310287d5808ec Mon Sep 17 00:00:00 2001
From: Daniel Garnier-Moiroux
Date: Thu, 2 Apr 2026 12:00:54 +0200
Subject: [PATCH 11/17] Server transports: remove deprecated methods from
builder
Signed-off-by: Daniel Garnier-Moiroux
---
.../HttpClientSseClientTransport.java | 18 ------
.../HttpClientStreamableHttpTransport.java | 18 ------
.../HttpClientSseClientTransportTests.java | 60 -------------------
3 files changed, 96 deletions(-)
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 48bd2f416..2e639f3c5 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
@@ -240,24 +240,6 @@ public Builder requestBuilder(HttpRequest.Builder requestBuilder) {
return this;
}
- /**
- * Applies the given consumer to the shared {@link HttpRequest.Builder} once,
- * at build time. Any headers set here are frozen into the template and
- * cannot be updated after the transport is built.
- * @param requestCustomizer the consumer to customize the HTTP request builder
- * @return this builder
- * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable
- * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}
- * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for
- * dynamic per-request customization.
- */
- @Deprecated
- public Builder customizeRequest(final Consumer requestCustomizer) {
- Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
- requestCustomizer.accept(requestBuilder);
- return this;
- }
-
/**
* Sets the JSON mapper implementation to use for serialization/deserialization.
* @param jsonMapper the JSON mapper
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
index 9e9b7f923..b751b0ded 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
@@ -737,24 +737,6 @@ public Builder requestBuilder(HttpRequest.Builder requestBuilder) {
return this;
}
- /**
- * Applies the given consumer to the shared {@link HttpRequest.Builder} once,
- * at build time. Any headers set here are frozen into the template and
- * cannot be updated after the transport is built.
- * @param requestCustomizer the consumer to customize the HTTP request builder
- * @return this builder
- * @deprecated Use {@link #requestBuilder(HttpRequest.Builder)} for stable
- * headers, or {@link #httpRequestCustomizer(McpSyncHttpClientRequestCustomizer)}
- * / {@link #asyncHttpRequestCustomizer(McpAsyncHttpClientRequestCustomizer)} for
- * dynamic per-request customization.
- */
- @Deprecated
- public Builder customizeRequest(final Consumer requestCustomizer) {
- Assert.notNull(requestCustomizer, "requestCustomizer must not be null");
- requestCustomizer.accept(requestBuilder);
- return this;
- }
-
/**
* Configure a custom {@link McpJsonMapper} implementation to use.
* @param jsonMapper instance to use
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..f3bc17f5b 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
@@ -333,66 +333,6 @@ void testCustomizeClient() {
customizedTransport.closeGracefully().block();
}
- @Test
- void testCustomizeRequest() {
- // Create an atomic boolean to verify the customizer was called
- AtomicBoolean customizerCalled = new AtomicBoolean(false);
-
- // Create a reference to store the custom header value
- AtomicReference headerName = new AtomicReference<>();
- AtomicReference headerValue = new AtomicReference<>();
-
- // Create a transport with the customizer
- HttpClientSseClientTransport customizedTransport = HttpClientSseClientTransport.builder(host)
- // Create a request customizer that adds a custom header
- .customizeRequest(builder -> {
- builder.header("X-Custom-Header", "test-value");
- customizerCalled.set(true);
-
- // Create a new request to verify the header was set
- HttpRequest request = builder.uri(URI.create("http://example.com")).build();
- headerName.set("X-Custom-Header");
- headerValue.set(request.headers().firstValue("X-Custom-Header").orElse(null));
- })
- .build();
-
- // Verify the customizer was called
- assertThat(customizerCalled.get()).isTrue();
-
- // Verify the header was set correctly
- assertThat(headerName.get()).isEqualTo("X-Custom-Header");
- assertThat(headerValue.get()).isEqualTo("test-value");
-
- // Clean up
- customizedTransport.closeGracefully().block();
- }
-
- @Test
- void testChainedCustomizations() {
- // Create atomic booleans to verify both customizers were called
- AtomicBoolean clientCustomizerCalled = new AtomicBoolean(false);
- AtomicBoolean requestCustomizerCalled = new AtomicBoolean(false);
-
- // Create a transport with both customizers chained
- HttpClientSseClientTransport customizedTransport = HttpClientSseClientTransport.builder(host)
- .customizeClient(builder -> {
- builder.connectTimeout(Duration.ofSeconds(30));
- clientCustomizerCalled.set(true);
- })
- .customizeRequest(builder -> {
- builder.header("X-Api-Key", "test-api-key");
- requestCustomizerCalled.set(true);
- })
- .build();
-
- // Verify both customizers were called
- assertThat(clientCustomizerCalled.get()).isTrue();
- assertThat(requestCustomizerCalled.get()).isTrue();
-
- // Clean up
- customizedTransport.closeGracefully().block();
- }
-
@Test
void testRequestCustomizer() {
var mockCustomizer = mock(McpSyncHttpClientRequestCustomizer.class);
From 5e77762eebe4bc26d9c93c877a40e9a9c46ad82e Mon Sep 17 00:00:00 2001
From: Daniel Garnier-Moiroux
Date: Thu, 2 Apr 2026 15:41:07 +0200
Subject: [PATCH 12/17] HttpClientStreamableHttpTransport: handle HTTP 405
- Forward-port of #900
Signed-off-by: Daniel Garnier-Moiroux
---
.../HttpClientStreamableHttpTransport.java | 8 +++----
...eamableHttpTransportErrorHandlingTest.java | 22 ++++++++++++++++++-
2 files changed, 25 insertions(+), 5 deletions(-)
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
index b751b0ded..86acf4e99 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
@@ -298,6 +298,10 @@ private Mono reconnect(McpTransportStream stream) {
"Authorization error connecting to SSE stream",
responseEvent.responseInfo()));
}
+ else if (statusCode == METHOD_NOT_ALLOWED) {
+ logger.debug("The server does not support SSE streams, using request-response mode.");
+ return Flux.empty();
+ }
if (!(responseEvent instanceof ResponseSubscribers.SseResponseEvent sseResponseEvent)) {
return Flux.error(new McpTransportException(
@@ -344,10 +348,6 @@ else if (statusCode >= 200 && statusCode < 300) {
return Flux.empty();
}
}
- else if (statusCode == METHOD_NOT_ALLOWED) { // NotAllowed
- logger.debug("The server does not support SSE streams, using request-response mode.");
- return Flux.empty();
- }
else if (statusCode == NOT_FOUND) {
if (transportSession != null && transportSession.sessionId().isPresent()) {
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
index c4857e5b4..d3793ca01 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransportErrorHandlingTest.java
@@ -18,7 +18,6 @@
import com.sun.net.httpserver.HttpServer;
import io.modelcontextprotocol.client.transport.customizer.McpHttpClientAuthorizationErrorHandler;
import io.modelcontextprotocol.common.McpTransportContext;
-import org.reactivestreams.Publisher;
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
import io.modelcontextprotocol.spec.HttpHeaders;
import io.modelcontextprotocol.spec.McpClientTransport;
@@ -34,6 +33,7 @@
import org.junit.jupiter.api.Timeout;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
+import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@@ -369,6 +369,26 @@ else if (status == 404) {
StepVerifier.create(transport.closeGracefully()).verifyComplete();
}
+ @Test
+ void test405OnConnectReturnsEmptyFlux() {
+ serverSseResponseStatus.set(405);
+ AtomicReference capturedException = new AtomicReference<>();
+ var transport = HttpClientStreamableHttpTransport.builder(HOST).openConnectionOnStartup(true).build();
+ transport.setExceptionHandler(capturedException::set);
+
+ var messages = new ArrayList();
+ StepVerifier.create(transport.connect(msg -> msg.doOnNext(messages::add))).verifyComplete();
+
+ Awaitility.await()
+ .atMost(Duration.ofSeconds(1))
+ .untilAsserted(() -> assertThat(processedSseConnectCount.get()).isEqualTo(1));
+
+ assertThat(messages).isEmpty();
+ assertThat(capturedException.get()).isNull();
+
+ StepVerifier.create(transport.closeGracefully()).verifyComplete();
+ }
+
@Nested
class AuthorizationError {
From 8fd9903e625e82552183ba1fb0c61a0838d2a969 Mon Sep 17 00:00:00 2001
From: RameshReddy Adutla
Date: Wed, 4 Mar 2026 20:59:04 +0000
Subject: [PATCH 13/17] Fix UTF-8 encoding for non-ASCII tool names in HTTP
client transports
Both HttpClientSseClientTransport and HttpClientStreamableHttpTransport
set Content-Type to 'application/json' without specifying the charset.
While Java's BodyPublishers.ofString() uses UTF-8 by default, the
missing charset in the header can cause the server to interpret the
request body using a different encoding (e.g., ISO-8859-1), corrupting
non-ASCII characters such as Chinese tool names.
Explicitly set Content-Type to 'application/json; charset=utf-8' in
POST requests on both client transports.
Fixes #260
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Daniel Garnier-Moiroux
Signed-off-by: Daniel Garnier-Moiroux
---
.../HttpClientSseClientTransport.java | 2 +-
.../HttpClientStreamableHttpTransport.java | 4 +-
...stractMcpClientServerIntegrationTests.java | 58 +++++++++++++++++++
3 files changed, 62 insertions(+), 2 deletions(-)
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 2e639f3c5..70d8b68e3 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
@@ -445,7 +445,7 @@ private Mono> sendHttpPost(final String endpoint, final Str
return Mono.deferContextual(ctx -> {
var builder = this.requestBuilder.copy()
.uri(requestUri)
- .header(HttpHeaders.CONTENT_TYPE, "application/json")
+ .header(HttpHeaders.CONTENT_TYPE, "application/json; charset=utf-8")
.header(MCP_PROTOCOL_VERSION_HEADER_NAME, MCP_PROTOCOL_VERSION)
.POST(HttpRequest.BodyPublishers.ofString(body));
var transportContext = ctx.getOrDefault(McpTransportContext.KEY, McpTransportContext.EMPTY);
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
index 86acf4e99..142c0302c 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java
@@ -102,6 +102,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
private static final String APPLICATION_JSON = "application/json";
+ private static final String APPLICATION_JSON_UTF8 = "application/json; charset=utf-8";
+
private static final String TEXT_EVENT_STREAM = "text/event-stream";
public static int NOT_FOUND = 404;
@@ -477,7 +479,7 @@ public Mono sendMessage(McpSchema.JSONRPCMessage sentMessage) {
var builder = requestBuilder.uri(uri)
.header(HttpHeaders.ACCEPT, APPLICATION_JSON + ", " + TEXT_EVENT_STREAM)
- .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON)
+ .header(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON_UTF8)
.header(HttpHeaders.CACHE_CONTROL, "no-cache")
.header(HttpHeaders.PROTOCOL_VERSION,
ctx.getOrDefault(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java
index e5d55c39d..5c2d77f2a 100644
--- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java
@@ -23,6 +23,7 @@
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.common.McpTransportContext;
+import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
@@ -47,6 +48,7 @@
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
import io.modelcontextprotocol.spec.McpSchema.TextContent;
import io.modelcontextprotocol.spec.McpSchema.Tool;
+import io.modelcontextprotocol.util.McpJsonMapperUtils;
import io.modelcontextprotocol.util.Utils;
import net.javacrumbs.jsonunit.core.Option;
import org.junit.jupiter.params.ParameterizedTest;
@@ -914,6 +916,62 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) {
}
}
+ @ParameterizedTest(name = "{0} : {displayName} ")
+ @MethodSource("clientsForTesting")
+ void testToolWithNonAsciiCharacters(String clientType) {
+ var clientBuilder = clientBuilders.get(clientType);
+
+ String inputSchema = """
+ {
+ "type": "object",
+ "properties": {
+ "username": { "type": "string" }
+ },
+ "required": ["username"]
+ }
+ """;
+
+ McpServerFeatures.SyncToolSpecification nonAsciiTool = McpServerFeatures.SyncToolSpecification.builder()
+ .tool(Tool.builder()
+ .name("greeter")
+ .description("打招呼")
+ .inputSchema(McpJsonDefaults.getMapper(), inputSchema)
+ .build())
+ .callHandler((exchange, request) -> {
+ String username = (String) request.arguments().get("username");
+ return McpSchema.CallToolResult.builder()
+ .addContent(new McpSchema.TextContent("Hello " + username))
+ .build();
+ })
+ .build();
+
+ var mcpServer = prepareSyncServerBuilder().capabilities(ServerCapabilities.builder().tools(true).build())
+ .tools(nonAsciiTool)
+ .build();
+
+ try (var mcpClient = clientBuilder.build()) {
+
+ InitializeResult initResult = mcpClient.initialize();
+ assertThat(initResult).isNotNull();
+
+ var tools = mcpClient.listTools().tools();
+ assertThat(tools).hasSize(1);
+ assertThat(tools.get(0).name()).isEqualTo("greeter");
+ assertThat(tools.get(0).description()).isEqualTo("打招呼");
+
+ CallToolResult response = mcpClient
+ .callTool(new McpSchema.CallToolRequest("greeter", Map.of("username", "测试用户")));
+
+ assertThat(response).isNotNull();
+ assertThat(response.isError()).isFalse();
+ assertThat(response.content()).hasSize(1);
+ assertThat(((McpSchema.TextContent) response.content().get(0)).text()).isEqualTo("Hello 测试用户");
+ }
+ finally {
+ mcpServer.closeGracefully();
+ }
+ }
+
@ParameterizedTest(name = "{0} : {displayName} ")
@MethodSource("clientsForTesting")
void testToolListChangeHandlingSuccess(String clientType) {
From eaa0c691f0c9beb998921451f3a1db00d5a932dd Mon Sep 17 00:00:00 2001
From: smohite04 <71900919+smohite04@users.noreply.github.com>
Date: Thu, 9 Apr 2026 01:40:35 -0700
Subject: [PATCH 14/17] feat: add support for meta parameter in client
paginated list queries (#906)
* feat: add support for meta parameter in client paginated list queries#
- resources/list
- resources/templates/list
- prompts/list
- tools/list
paginated list operations extended in this review:
- listResources(String cursor, Map meta)
- listResourceTemplates(String cursor, Map meta)
- listPrompts(String cursor, Map meta)
Closes #907
Co-authored-by: SHEETAL MOHITE
Signed-off-by: Daniel Garnier-Moiroux
---
.../client/McpAsyncClient.java | 83 ++-
.../client/McpSyncClient.java | 49 ++
.../client/AbstractMcpSyncClientTests.java | 52 ++
.../client/McpAsyncClientTests.java | 557 ++++++++++++++++++
4 files changed, 733 insertions(+), 8 deletions(-)
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
index 93fcc332a..8aac5edf9 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
@@ -303,7 +303,7 @@ public class McpAsyncClient {
return Mono.empty();
}
- return this.listToolsInternal(init, McpSchema.FIRST_PAGE).doOnNext(listToolsResult -> {
+ return this.listToolsInternal(init, McpSchema.FIRST_PAGE, null).doOnNext(listToolsResult -> {
listToolsResult.tools()
.forEach(tool -> logger.debug("Tool {} schema: {}", tool.name(), tool.outputSchema()));
if (enableCallToolSchemaCaching && listToolsResult.tools() != null) {
@@ -645,16 +645,27 @@ public Mono listTools() {
* @return A Mono that emits the list of tools result
*/
public Mono listTools(String cursor) {
- return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor));
+ return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, null));
}
- private Mono listToolsInternal(Initialization init, String cursor) {
+ /**
+ * Retrieves a paginated list of tools with optional metadata.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return A Mono that emits the list of tools result
+ */
+ public Mono listTools(String cursor, java.util.Map meta) {
+ return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, meta));
+ }
+
+ private Mono listToolsInternal(Initialization init, String cursor,
+ java.util.Map meta) {
if (init.initializeResult().capabilities().tools() == null) {
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
}
return init.mcpSession()
- .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
+ .sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor, meta),
LIST_TOOLS_RESULT_TYPE_REF)
.doOnNext(result -> {
// Validate tool names (warn only)
@@ -725,12 +736,31 @@ public Mono listResources() {
* @see #readResource(McpSchema.Resource)
*/
public Mono listResources(String cursor) {
+ return this.listResourcesInternal(cursor, null);
+ }
+
+ /**
+ * Retrieves a paginated list of resources provided by the server. Resources represent
+ * any kind of UTF-8 encoded data that an MCP server makes available to clients, such
+ * as database records, API responses, log files, and more.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return A Mono that completes with the list of resources result.
+ * @see McpSchema.ListResourcesResult
+ * @see #readResource(McpSchema.Resource)
+ */
+ public Mono listResources(String cursor, java.util.Map meta) {
+ return this.listResourcesInternal(cursor, meta);
+ }
+
+ private Mono listResourcesInternal(String cursor,
+ java.util.Map meta) {
return this.initializer.withInitialization("listing resources", init -> {
if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
}
return init.mcpSession()
- .sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
+ .sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor, meta),
LIST_RESOURCES_RESULT_TYPE_REF);
});
}
@@ -795,12 +825,31 @@ public Mono listResourceTemplates() {
* @see McpSchema.ListResourceTemplatesResult
*/
public Mono listResourceTemplates(String cursor) {
+ return this.listResourceTemplatesInternal(cursor, null);
+ }
+
+ /**
+ * Retrieves a paginated list of resource templates provided by the server. Resource
+ * templates allow servers to expose parameterized resources using URI templates,
+ * enabling dynamic resource access based on variable parameters.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return A Mono that completes with the list of resource templates result.
+ * @see McpSchema.ListResourceTemplatesResult
+ */
+ public Mono listResourceTemplates(String cursor,
+ java.util.Map meta) {
+ return this.listResourceTemplatesInternal(cursor, meta);
+ }
+
+ private Mono listResourceTemplatesInternal(String cursor,
+ java.util.Map meta) {
return this.initializer.withInitialization("listing resource templates", init -> {
if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
}
return init.mcpSession()
- .sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor),
+ .sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor, meta),
LIST_RESOURCE_TEMPLATES_RESULT_TYPE_REF);
});
}
@@ -895,8 +944,26 @@ public Mono listPrompts() {
* @see #getPrompt(GetPromptRequest)
*/
public Mono listPrompts(String cursor) {
- return this.initializer.withInitialization("listing prompts", init -> init.mcpSession()
- .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor), LIST_PROMPTS_RESULT_TYPE_REF));
+ return this.listPromptsInternal(cursor, null);
+ }
+
+ /**
+ * Retrieves a paginated list of prompts with optional metadata.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return A Mono that completes with the list of prompts result.
+ * @see McpSchema.ListPromptsResult
+ * @see #getPrompt(GetPromptRequest)
+ */
+ public Mono listPrompts(String cursor, java.util.Map meta) {
+ return this.listPromptsInternal(cursor, meta);
+ }
+
+ private Mono listPromptsInternal(String cursor, java.util.Map meta) {
+ return this.initializer.withInitialization("listing prompts",
+ init -> init.mcpSession()
+ .sendRequest(McpSchema.METHOD_PROMPT_LIST, new PaginatedRequest(cursor, meta),
+ LIST_PROMPTS_RESULT_TYPE_REF));
}
/**
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java
index 7fdaa8941..cd67b7401 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpSyncClient.java
@@ -259,6 +259,18 @@ public McpSchema.ListToolsResult listTools(String cursor) {
}
+ /**
+ * Retrieves a paginated list of tools provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return The list of tools result containing: - tools: List of available tools, each
+ * with a name, description, and input schema - nextCursor: Optional cursor for
+ * pagination if more tools are available
+ */
+ public McpSchema.ListToolsResult listTools(String cursor, java.util.Map meta) {
+ return withProvidedContext(this.delegate.listTools(cursor, meta)).block();
+ }
+
// --------------------------
// Resources
// --------------------------
@@ -282,6 +294,17 @@ public McpSchema.ListResourcesResult listResources(String cursor) {
}
+ /**
+ * Retrieves a paginated list of resources with optional metadata.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return The list of resources result
+ */
+ public McpSchema.ListResourcesResult listResources(String cursor, java.util.Map meta) {
+ return withProvidedContext(this.delegate.listResources(cursor, meta)).block();
+
+ }
+
/**
* Send a resources/read request.
* @param resource the resource to read
@@ -324,6 +347,21 @@ public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor
}
+ /**
+ * Resource templates allow servers to expose parameterized resources using URI
+ * templates. Arguments may be auto-completed through the completion API.
+ *
+ * Retrieves a paginated list of resource templates provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return The list of resource templates result.
+ */
+ public McpSchema.ListResourceTemplatesResult listResourceTemplates(String cursor,
+ java.util.Map meta) {
+ return withProvidedContext(this.delegate.listResourceTemplates(cursor, meta)).block();
+
+ }
+
/**
* Subscriptions. The protocol supports optional subscriptions to resource changes.
* Clients can subscribe to specific resources and receive notifications when they
@@ -370,6 +408,17 @@ public ListPromptsResult listPrompts(String cursor) {
}
+ /**
+ * Retrieves a paginated list of prompts provided by the server.
+ * @param cursor Optional pagination cursor from a previous list request
+ * @param meta Optional metadata to include in the request (_meta field)
+ * @return The list of prompts result.
+ */
+ public ListPromptsResult listPrompts(String cursor, java.util.Map meta) {
+ return withProvidedContext(this.delegate.listPrompts(cursor, meta)).block();
+
+ }
+
public GetPromptResult getPrompt(GetPromptRequest getPromptRequest) {
return withProvidedContext(this.delegate.getPrompt(getPromptRequest)).block();
}
diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java
index 26d60568a..0c38ddafe 100644
--- a/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java
+++ b/mcp-test/src/main/java/io/modelcontextprotocol/client/AbstractMcpSyncClientTests.java
@@ -154,6 +154,19 @@ void testListTools() {
});
}
+ @Test
+ void testListToolsWithMeta() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ java.util.Map meta = java.util.Map.of("requestId", "test-123");
+ ListToolsResult tools = mcpSyncClient.listTools(McpSchema.FIRST_PAGE, meta);
+
+ assertThat(tools).isNotNull().satisfies(result -> {
+ assertThat(result.tools()).isNotNull().isNotEmpty();
+ });
+ });
+ }
+
@Test
void testListAllTools() {
withClient(createMcpTransport(), mcpSyncClient -> {
@@ -678,4 +691,43 @@ void testProgressConsumer() {
});
}
+ @Test
+ void testListResourcesWithMeta() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ java.util.Map meta = java.util.Map.of("requestId", "test-123");
+ ListResourcesResult resources = mcpSyncClient.listResources(McpSchema.FIRST_PAGE, meta);
+
+ assertThat(resources).isNotNull().satisfies(result -> {
+ assertThat(result.resources()).isNotNull();
+ });
+ });
+ }
+
+ @Test
+ void testListResourceTemplatesWithMeta() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ java.util.Map meta = java.util.Map.of("requestId", "test-123");
+ ListResourceTemplatesResult result = mcpSyncClient.listResourceTemplates(McpSchema.FIRST_PAGE, meta);
+
+ assertThat(result).isNotNull().satisfies(r -> {
+ assertThat(r.resourceTemplates()).isNotNull();
+ });
+ });
+ }
+
+ @Test
+ void testListPromptsWithMeta() {
+ withClient(createMcpTransport(), mcpSyncClient -> {
+ mcpSyncClient.initialize();
+ java.util.Map meta = java.util.Map.of("requestId", "test-123");
+ McpSchema.ListPromptsResult result = mcpSyncClient.listPrompts(McpSchema.FIRST_PAGE, meta);
+
+ assertThat(result).isNotNull().satisfies(r -> {
+ assertThat(r.prompts()).isNotNull();
+ });
+ });
+ }
+
}
diff --git a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
index 48bf1da5b..bfe9d5df9 100644
--- a/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
+++ b/mcp-test/src/test/java/io/modelcontextprotocol/client/McpAsyncClientTests.java
@@ -307,4 +307,561 @@ public java.lang.reflect.Type getType() {
assertThat(names).containsExactlyInAnyOrder("subtract", "add");
}
+ @Test
+ void testListToolsWithCursorAndMeta() {
+ McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build();
+ McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool), null);
+
+ // Use array to capture from anonymous class
+ McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1];
+
+ McpClientTransport transport = new McpClientTransport() {
+ Function, Mono> handler;
+
+ @Override
+ public Mono connect(
+ Function, Mono> handler) {
+ return Mono.deferContextual(ctx -> {
+ this.handler = handler;
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
+ return Mono.empty();
+ }
+
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT,
+ null);
+ }
+ else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) {
+ capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult,
+ null);
+ }
+ else {
+ return Mono.empty();
+ }
+
+ return handler.apply(Mono.just(response)).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+ };
+
+ McpAsyncClient client = McpClient.async(transport).build();
+
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListToolsResult toolsResult = client.listTools("cursor-1", meta).block();
+ assertThat(toolsResult).isNotNull();
+ assertThat(toolsResult.tools()).hasSize(1);
+ assertThat(capturedRequest[0]).isNotNull();
+ assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1");
+ assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue");
+ }
+
+ @Test
+ void testSyncListToolsWithCursorAndMeta() {
+ McpSchema.Tool addTool = McpSchema.Tool.builder().name("add").description("calculate add").build();
+ McpSchema.ListToolsResult mockToolsResult = new McpSchema.ListToolsResult(List.of(addTool), null);
+
+ McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1];
+
+ McpClientTransport transport = new McpClientTransport() {
+ Function, Mono> handler;
+
+ @Override
+ public Mono connect(
+ Function, Mono> handler) {
+ return Mono.deferContextual(ctx -> {
+ this.handler = handler;
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
+ return Mono.empty();
+ }
+
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), MOCK_INIT_RESULT,
+ null);
+ }
+ else if (McpSchema.METHOD_TOOLS_LIST.equals(request.method())) {
+ capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockToolsResult,
+ null);
+ }
+ else {
+ return Mono.empty();
+ }
+
+ return handler.apply(Mono.just(response)).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+ };
+
+ McpSyncClient client = McpClient.sync(transport).build();
+
+ Map meta = Map.of("requestId", "test-123");
+ McpSchema.ListToolsResult toolsResult = client.listTools("cursor-1", meta);
+ assertThat(toolsResult).isNotNull();
+ assertThat(toolsResult.tools()).hasSize(1);
+ assertThat(capturedRequest[0]).isNotNull();
+ assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1");
+ assertThat(capturedRequest[0].meta()).containsEntry("requestId", "test-123");
+ }
+
+ @Test
+ void testListResourcesWithCursorAndMeta() {
+ McpSchema.Resource mockResource = McpSchema.Resource.builder().uri("file:///test.txt").name("test.txt").build();
+ McpSchema.ListResourcesResult mockResult = new McpSchema.ListResourcesResult(List.of(mockResource), null);
+
+ McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build();
+ McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps,
+ MOCK_SERVER_INFO, null);
+
+ McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1];
+
+ McpClientTransport transport = new McpClientTransport() {
+ Function, Mono> handler;
+
+ @Override
+ public Mono connect(
+ Function, Mono> handler) {
+ return Mono.deferContextual(ctx -> {
+ this.handler = handler;
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
+ return Mono.empty();
+ }
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null);
+ }
+ else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) {
+ capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null);
+ }
+ else {
+ return Mono.empty();
+ }
+ return handler.apply(Mono.just(response)).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+ };
+
+ McpAsyncClient client = McpClient.async(transport).build();
+
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListResourcesResult result = client.listResources("cursor-1", meta).block();
+ assertThat(result).isNotNull();
+ assertThat(result.resources()).hasSize(1);
+ assertThat(capturedRequest[0]).isNotNull();
+ assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1");
+ assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue");
+ }
+
+ @Test
+ void testSyncListResourcesWithCursorAndMeta() {
+ McpSchema.Resource mockResource = McpSchema.Resource.builder().uri("file:///test.txt").name("test.txt").build();
+ McpSchema.ListResourcesResult mockResult = new McpSchema.ListResourcesResult(List.of(mockResource), null);
+
+ McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build();
+ McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps,
+ MOCK_SERVER_INFO, null);
+
+ McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1];
+
+ McpClientTransport transport = new McpClientTransport() {
+ Function, Mono> handler;
+
+ @Override
+ public Mono connect(
+ Function, Mono> handler) {
+ return Mono.deferContextual(ctx -> {
+ this.handler = handler;
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
+ return Mono.empty();
+ }
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null);
+ }
+ else if (McpSchema.METHOD_RESOURCES_LIST.equals(request.method())) {
+ capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null);
+ }
+ else {
+ return Mono.empty();
+ }
+ return handler.apply(Mono.just(response)).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+ };
+
+ McpSyncClient client = McpClient.sync(transport).build();
+
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListResourcesResult result = client.listResources("cursor-1", meta);
+ assertThat(result).isNotNull();
+ assertThat(result.resources()).hasSize(1);
+ assertThat(capturedRequest[0]).isNotNull();
+ assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1");
+ assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue");
+ }
+
+ @Test
+ void testListResourceTemplatesWithCursorAndMeta() {
+ McpSchema.ResourceTemplate mockTemplate = new McpSchema.ResourceTemplate("file:///{name}", "template", null,
+ null, null);
+ McpSchema.ListResourceTemplatesResult mockResult = new McpSchema.ListResourceTemplatesResult(
+ List.of(mockTemplate), null);
+
+ McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build();
+ McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps,
+ MOCK_SERVER_INFO, null);
+
+ McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1];
+
+ McpClientTransport transport = new McpClientTransport() {
+ Function, Mono> handler;
+
+ @Override
+ public Mono connect(
+ Function, Mono> handler) {
+ return Mono.deferContextual(ctx -> {
+ this.handler = handler;
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
+ return Mono.empty();
+ }
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null);
+ }
+ else if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(request.method())) {
+ capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null);
+ }
+ else {
+ return Mono.empty();
+ }
+ return handler.apply(Mono.just(response)).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+ };
+
+ McpAsyncClient client = McpClient.async(transport).build();
+
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates("cursor-1", meta).block();
+ assertThat(result).isNotNull();
+ assertThat(result.resourceTemplates()).hasSize(1);
+ assertThat(capturedRequest[0]).isNotNull();
+ assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1");
+ assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue");
+ }
+
+ @Test
+ void testSyncListResourceTemplatesWithCursorAndMeta() {
+ McpSchema.ResourceTemplate mockTemplate = new McpSchema.ResourceTemplate("file:///{name}", "template", null,
+ null, null);
+ McpSchema.ListResourceTemplatesResult mockResult = new McpSchema.ListResourceTemplatesResult(
+ List.of(mockTemplate), null);
+
+ McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().resources(false, false).build();
+ McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps,
+ MOCK_SERVER_INFO, null);
+
+ McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1];
+
+ McpClientTransport transport = new McpClientTransport() {
+ Function, Mono> handler;
+
+ @Override
+ public Mono connect(
+ Function, Mono> handler) {
+ return Mono.deferContextual(ctx -> {
+ this.handler = handler;
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
+ return Mono.empty();
+ }
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null);
+ }
+ else if (McpSchema.METHOD_RESOURCES_TEMPLATES_LIST.equals(request.method())) {
+ capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null);
+ }
+ else {
+ return Mono.empty();
+ }
+ return handler.apply(Mono.just(response)).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+ };
+
+ McpSyncClient client = McpClient.sync(transport).build();
+
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListResourceTemplatesResult result = client.listResourceTemplates("cursor-1", meta);
+ assertThat(result).isNotNull();
+ assertThat(result.resourceTemplates()).hasSize(1);
+ assertThat(capturedRequest[0]).isNotNull();
+ assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1");
+ assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue");
+ }
+
+ @Test
+ void testListPromptsWithCursorAndMeta() {
+ McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "A test prompt", List.of());
+ McpSchema.ListPromptsResult mockResult = new McpSchema.ListPromptsResult(List.of(mockPrompt), null);
+
+ McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().prompts(false).build();
+ McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps,
+ MOCK_SERVER_INFO, null);
+
+ McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1];
+
+ McpClientTransport transport = new McpClientTransport() {
+ Function, Mono> handler;
+
+ @Override
+ public Mono connect(
+ Function, Mono> handler) {
+ return Mono.deferContextual(ctx -> {
+ this.handler = handler;
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
+ return Mono.empty();
+ }
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null);
+ }
+ else if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) {
+ capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null);
+ }
+ else {
+ return Mono.empty();
+ }
+ return handler.apply(Mono.just(response)).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+ };
+
+ McpAsyncClient client = McpClient.async(transport).build();
+
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListPromptsResult result = client.listPrompts("cursor-1", meta).block();
+ assertThat(result).isNotNull();
+ assertThat(result.prompts()).hasSize(1);
+ assertThat(capturedRequest[0]).isNotNull();
+ assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1");
+ assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue");
+ }
+
+ @Test
+ void testSyncListPromptsWithCursorAndMeta() {
+ McpSchema.Prompt mockPrompt = new McpSchema.Prompt("test-prompt", "A test prompt", List.of());
+ McpSchema.ListPromptsResult mockResult = new McpSchema.ListPromptsResult(List.of(mockPrompt), null);
+
+ McpSchema.ServerCapabilities caps = McpSchema.ServerCapabilities.builder().prompts(false).build();
+ McpSchema.InitializeResult initResult = new McpSchema.InitializeResult(ProtocolVersions.MCP_2024_11_05, caps,
+ MOCK_SERVER_INFO, null);
+
+ McpSchema.PaginatedRequest[] capturedRequest = new McpSchema.PaginatedRequest[1];
+
+ McpClientTransport transport = new McpClientTransport() {
+ Function, Mono> handler;
+
+ @Override
+ public Mono connect(
+ Function, Mono> handler) {
+ return Mono.deferContextual(ctx -> {
+ this.handler = handler;
+ return Mono.empty();
+ });
+ }
+
+ @Override
+ public Mono closeGracefully() {
+ return Mono.empty();
+ }
+
+ @Override
+ public Mono sendMessage(McpSchema.JSONRPCMessage message) {
+ if (!(message instanceof McpSchema.JSONRPCRequest request)) {
+ return Mono.empty();
+ }
+ McpSchema.JSONRPCResponse response;
+ if (McpSchema.METHOD_INITIALIZE.equals(request.method())) {
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), initResult, null);
+ }
+ else if (McpSchema.METHOD_PROMPT_LIST.equals(request.method())) {
+ capturedRequest[0] = JSON_MAPPER.convertValue(request.params(), McpSchema.PaginatedRequest.class);
+ response = new McpSchema.JSONRPCResponse(McpSchema.JSONRPC_VERSION, request.id(), mockResult, null);
+ }
+ else {
+ return Mono.empty();
+ }
+ return handler.apply(Mono.just(response)).then();
+ }
+
+ @Override
+ public T unmarshalFrom(Object data, TypeRef typeRef) {
+ return JSON_MAPPER.convertValue(data, new TypeRef<>() {
+ @Override
+ public java.lang.reflect.Type getType() {
+ return typeRef.getType();
+ }
+ });
+ }
+ };
+
+ McpSyncClient client = McpClient.sync(transport).build();
+
+ Map meta = Map.of("customKey", "customValue");
+ McpSchema.ListPromptsResult result = client.listPrompts("cursor-1", meta);
+ assertThat(result).isNotNull();
+ assertThat(result.prompts()).hasSize(1);
+ assertThat(capturedRequest[0]).isNotNull();
+ assertThat(capturedRequest[0].cursor()).isEqualTo("cursor-1");
+ assertThat(capturedRequest[0].meta()).containsEntry("customKey", "customValue");
+ }
+
}
From fcdc0d4c14363044dd4469d05aabb9b148be9a50 Mon Sep 17 00:00:00 2001
From: Daniel Garnier-Moiroux
Date: Thu, 9 Apr 2026 11:11:07 +0200
Subject: [PATCH 15/17] Polish gh-906
---
.../client/McpAsyncClient.java | 18 +-
.../client/McpSyncClient.java | 10 +-
.../client/AbstractMcpSyncClientTests.java | 8 +-
.../client/McpAsyncClientTests.java | 668 +++---------------
4 files changed, 116 insertions(+), 588 deletions(-)
diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
index 8aac5edf9..434c07a1b 100644
--- a/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
+++ b/mcp-core/src/main/java/io/modelcontextprotocol/client/McpAsyncClient.java
@@ -654,12 +654,12 @@ public Mono listTools(String cursor) {
* @param meta Optional metadata to include in the request (_meta field)
* @return A Mono that emits the list of tools result
*/
- public Mono listTools(String cursor, java.util.Map meta) {
+ public Mono listTools(String cursor, Map meta) {
return this.initializer.withInitialization("listing tools", init -> this.listToolsInternal(init, cursor, meta));
}
private Mono listToolsInternal(Initialization init, String cursor,
- java.util.Map meta) {
+ Map meta) {
if (init.initializeResult().capabilities().tools() == null) {
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
@@ -749,12 +749,11 @@ public Mono listResources(String cursor) {
* @see McpSchema.ListResourcesResult
* @see #readResource(McpSchema.Resource)
*/
- public Mono listResources(String cursor, java.util.Map meta) {
+ public Mono listResources(String cursor, Map meta) {
return this.listResourcesInternal(cursor, meta);
}
- private Mono listResourcesInternal(String cursor,
- java.util.Map meta) {
+ private Mono listResourcesInternal(String cursor, Map meta) {
return this.initializer.withInitialization("listing resources", init -> {
if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
@@ -837,13 +836,12 @@ public Mono listResourceTemplates(String
* @return A Mono that completes with the list of resource templates result.
* @see McpSchema.ListResourceTemplatesResult
*/
- public Mono listResourceTemplates(String cursor,
- java.util.Map meta) {
+ public Mono listResourceTemplates(String cursor, Map meta) {
return this.listResourceTemplatesInternal(cursor, meta);
}
private Mono listResourceTemplatesInternal(String cursor,
- java.util.Map meta) {
+ Map meta) {
return this.initializer.withInitialization("listing resource templates", init -> {
if (init.initializeResult().capabilities().resources() == null) {
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
@@ -955,11 +953,11 @@ public Mono listPrompts(String cursor) {
* @see McpSchema.ListPromptsResult
* @see #getPrompt(GetPromptRequest)
*/
- public Mono listPrompts(String cursor, java.util.Map meta) {
+ public Mono