-
Notifications
You must be signed in to change notification settings - Fork 889
Expose request URI in McpHttpClientAuthorizationErrorHandler #980
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| /* | ||
| * Copyright 2026-2026 the original author or authors. | ||
| */ | ||
|
|
||
| package io.modelcontextprotocol.client.transport; | ||
|
|
||
| import java.net.URI; | ||
| import java.net.http.HttpHeaders; | ||
| import java.net.http.HttpRequest; | ||
| import java.net.http.HttpRequest.BodyPublisher; | ||
|
|
||
| /** | ||
| * Captures information about an HTTP request. We use this instead of passing the plain | ||
| * {@link HttpRequest} object because we want to avoid retaining a reference to the | ||
| * request's {@link BodyPublisher}. | ||
| * | ||
| * @param requestUri the HTTP request URI | ||
| * @param method the HTTP method | ||
| * @param headers the HTTP request headers | ||
| * @author Daniel Garnier-Moiroux | ||
| */ | ||
| public record HttpRequestSnapshot(URI requestUri, String method, HttpHeaders headers) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| /* | ||
| * Copyright 2026-2026 the original author or authors. | ||
| */ | ||
|
|
||
| package io.modelcontextprotocol.client.transport.customizer; | ||
|
|
||
| import java.net.URI; | ||
| import java.net.http.HttpResponse; | ||
|
|
||
| import io.modelcontextprotocol.client.transport.HttpRequestSnapshot; | ||
| 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 <a href= | ||
| * "https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization">MCP | ||
| * Specification: Authorization</a> | ||
| * @author Daniel Garnier-Moiroux | ||
| */ | ||
| public interface McpHttpClientTransportAuthorizationErrorHandler { | ||
|
|
||
| /** | ||
| * 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. | ||
| * <p> | ||
| * If the returned {@link Publisher} errors, the error will be propagated to the | ||
| * calling method, to be handled by the caller. | ||
| * <p> | ||
| * The number of retries is bounded by {@link #maxRetries()}. | ||
| * @param responseInfo the HTTP response information | ||
| * @param requestSnapshot the HTTP request snapshot that failed authorization | ||
| * @param context the MCP client transport context | ||
| * @return {@link Publisher} emitting true if the original request should be replayed, | ||
| * false otherwise. | ||
| */ | ||
| Publisher<Boolean> handle(HttpResponse.ResponseInfo responseInfo, HttpRequestSnapshot requestSnapshot, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Somehow having the response info before the request info feels counter-intuitive. Can we make the |
||
| 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}. | ||
| * <p> | ||
| * 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. | ||
| */ | ||
| McpHttpClientTransportAuthorizationErrorHandler NOOP = new Noop(); | ||
|
|
||
| /** | ||
| * Create a {@link McpHttpClientTransportAuthorizationErrorHandler} 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 McpHttpClientTransportAuthorizationErrorHandler fromSync(Sync handler) { | ||
| return (info, snapshot, context) -> Mono.fromCallable(() -> handler.handle(info, snapshot, 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 requestSnapshot the HTTP request snapshot that failed authorization | ||
| * @param context the MCP client transport context | ||
| * @return true if the original request should be replayed, false otherwise. | ||
| */ | ||
| boolean handle(HttpResponse.ResponseInfo responseInfo, HttpRequestSnapshot requestSnapshot, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If possible, let's change the order here too. |
||
| McpTransportContext context); | ||
|
|
||
| } | ||
|
|
||
| class Noop implements McpHttpClientTransportAuthorizationErrorHandler { | ||
|
|
||
| @Override | ||
| public Publisher<Boolean> handle(HttpResponse.ResponseInfo responseInfo, HttpRequestSnapshot requestSnapshot, | ||
| McpTransportContext context) { | ||
| return Mono.just(false); | ||
| } | ||
|
|
||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| /* | ||
| * Copyright 2026-2026 the original author or authors. | ||
| */ | ||
| package io.modelcontextprotocol.client.transport.customizer; | ||
|
|
||
| import java.net.URI; | ||
| import java.net.http.HttpResponse; | ||
|
|
||
| import io.modelcontextprotocol.common.McpTransportContext; | ||
| import io.modelcontextprotocol.client.transport.HttpRequestSnapshot; | ||
| import org.junit.jupiter.api.Test; | ||
| import reactor.test.StepVerifier; | ||
|
|
||
| import static org.mockito.Mockito.mock; | ||
|
|
||
| /** | ||
| * @author Daniel Garnier-Moiroux | ||
| */ | ||
| class McpHttpClientTransportAuthorizationErrorHandlerTest { | ||
|
|
||
| private final HttpResponse.ResponseInfo responseInfo = mock(HttpResponse.ResponseInfo.class); | ||
|
|
||
| private final HttpRequestSnapshot requestSnapshot = new HttpRequestSnapshot(URI.create("http://localhost/mcp"), | ||
| "GET", java.net.http.HttpHeaders.of(java.util.Map.of(), (a, b) -> true)); | ||
|
|
||
| private final McpTransportContext context = McpTransportContext.EMPTY; | ||
|
|
||
| @Test | ||
| void whenTrueThenRetry() { | ||
| McpHttpClientTransportAuthorizationErrorHandler handler = McpHttpClientTransportAuthorizationErrorHandler | ||
| .fromSync((info, snapshot, ctx) -> true); | ||
| StepVerifier.create(handler.handle(responseInfo, requestSnapshot, context)).expectNext(true).verifyComplete(); | ||
| } | ||
|
|
||
| @Test | ||
| void whenFalseThenError() { | ||
| McpHttpClientTransportAuthorizationErrorHandler handler = McpHttpClientTransportAuthorizationErrorHandler | ||
| .fromSync((info, snapshot, ctx) -> false); | ||
| StepVerifier.create(handler.handle(responseInfo, requestSnapshot, context)).expectNext(false).verifyComplete(); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test name and the behaviour don't seem to match. The test verifies completion, while the test name mentions error. Should it be aligned? |
||
| } | ||
|
|
||
| @Test | ||
| void whenExceptionThenPropagate() { | ||
| McpHttpClientTransportAuthorizationErrorHandler handler = McpHttpClientTransportAuthorizationErrorHandler | ||
| .fromSync((info, snapshot, ctx) -> { | ||
| throw new IllegalStateException("sync handler error"); | ||
| }); | ||
| StepVerifier.create(handler.handle(responseInfo, requestSnapshot, context)) | ||
| .expectErrorMatches(t -> t instanceof IllegalStateException && t.getMessage().equals("sync handler error")) | ||
| .verify(); | ||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wouldn't mind reversing the order of response and request here either :)