From 6782f967c60da33fcecf3822fe7da04540a1af0d Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Sun, 19 Jul 2026 20:47:48 +0700 Subject: [PATCH] Allow HttpClient BaseAddress as transport endpoint --- .../AutoDetectingClientSessionTransport.cs | 10 +-- .../Client/HttpClientTransport.cs | 15 ++-- .../Client/HttpClientTransportOptions.cs | 17 +++-- .../Client/SseClientSessionTransport.cs | 5 +- .../StreamableHttpClientSessionTransport.cs | 9 ++- .../Transport/HttpClientTransportTests.cs | 69 +++++++++++++++++++ 6 files changed, 102 insertions(+), 23 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index 015d4048d..2823f7d39 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -14,17 +14,19 @@ namespace ModelContextProtocol.Client; internal sealed partial class AutoDetectingClientSessionTransport : ITransport { private readonly HttpClientTransportOptions _options; + private readonly Uri _endpoint; private readonly McpHttpClient _httpClient; private readonly ILoggerFactory? _loggerFactory; private readonly ILogger _logger; private readonly string _name; private readonly Channel _messageChannel; - public AutoDetectingClientSessionTransport(string endpointName, HttpClientTransportOptions transportOptions, McpHttpClient httpClient, ILoggerFactory? loggerFactory) + public AutoDetectingClientSessionTransport(string endpointName, Uri endpoint, HttpClientTransportOptions transportOptions, McpHttpClient httpClient, ILoggerFactory? loggerFactory) { Throw.IfNull(transportOptions); Throw.IfNull(httpClient); + _endpoint = endpoint; _options = transportOptions; _httpClient = httpClient; _loggerFactory = loggerFactory; @@ -62,7 +64,7 @@ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellat private async Task InitializeAsync(JsonRpcMessage message, CancellationToken cancellationToken) { // Try StreamableHttp first - var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory); + var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _endpoint, _options, _httpClient, _messageChannel, _loggerFactory); try { @@ -117,7 +119,7 @@ private async Task InitializeSseTransportAsync(JsonRpcMessage message, Cancellat throw new InvalidOperationException("Streamable HTTP transport is required to resume an existing session."); } - var sseTransport = new SseClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory); + var sseTransport = new SseClientSessionTransport(_name, _endpoint, _options, _httpClient, _messageChannel, _loggerFactory); try { @@ -166,4 +168,4 @@ public async ValueTask DisposeAsync() [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using SSE transport.")] private partial void LogUsingSSE(string endpointName); -} \ No newline at end of file +} diff --git a/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs b/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs index 14044d2d7..3f37e4c08 100644 --- a/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs +++ b/src/ModelContextProtocol.Core/Client/HttpClientTransport.cs @@ -18,6 +18,7 @@ public sealed class HttpClientTransport : IClientTransport, IAsyncDisposable private readonly HttpClientTransportOptions _options; private readonly McpHttpClient _mcpHttpClient; private readonly ILoggerFactory? _loggerFactory; + private readonly Uri _endpoint; private readonly HttpClient? _ownedHttpClient; @@ -49,11 +50,15 @@ public HttpClientTransport(HttpClientTransportOptions transportOptions, HttpClie _options = transportOptions; _loggerFactory = loggerFactory; - Name = transportOptions.Name ?? transportOptions.Endpoint.ToString(); + _endpoint = transportOptions.Endpoint ?? httpClient.BaseAddress ?? + throw new ArgumentException( + $"Either '{nameof(HttpClientTransportOptions)}.{nameof(HttpClientTransportOptions.Endpoint)}' or '{nameof(HttpClient)}.{nameof(HttpClient.BaseAddress)}' must be set.", + nameof(transportOptions)); + Name = transportOptions.Name ?? _endpoint.ToString(); if (transportOptions.OAuth is { } clientOAuthOptions) { - _mcpHttpClient = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory); + _mcpHttpClient = new ClientOAuthProvider(_endpoint, clientOAuthOptions, httpClient, loggerFactory); } else { @@ -79,8 +84,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = return _options.TransportMode switch { - HttpTransportMode.AutoDetect => new AutoDetectingClientSessionTransport(Name, _options, _mcpHttpClient, _loggerFactory), - HttpTransportMode.StreamableHttp => new StreamableHttpClientSessionTransport(Name, _options, _mcpHttpClient, messageChannel: null, _loggerFactory), + HttpTransportMode.AutoDetect => new AutoDetectingClientSessionTransport(Name, _endpoint, _options, _mcpHttpClient, _loggerFactory), + HttpTransportMode.StreamableHttp => new StreamableHttpClientSessionTransport(Name, _endpoint, _options, _mcpHttpClient, messageChannel: null, _loggerFactory), HttpTransportMode.Sse => await ConnectSseTransportAsync(cancellationToken).ConfigureAwait(false), _ => throw new InvalidOperationException($"Unsupported transport mode: {_options.TransportMode}"), }; @@ -88,7 +93,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = private async Task ConnectSseTransportAsync(CancellationToken cancellationToken) { - var sessionTransport = new SseClientSessionTransport(Name, _options, _mcpHttpClient, messageChannel: null, _loggerFactory); + var sessionTransport = new SseClientSessionTransport(Name, _endpoint, _options, _mcpHttpClient, messageChannel: null, _loggerFactory); try { diff --git a/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs b/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs index ce95f65f3..65d9fbc1f 100644 --- a/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs +++ b/src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs @@ -8,24 +8,23 @@ namespace ModelContextProtocol.Client; public sealed class HttpClientTransportOptions { /// - /// Gets or sets the base address of the server for SSE connections. + /// Gets or sets the base address of the server for HTTP connections. /// - /// The value is . /// The value is not an absolute URI, or does not use the HTTP or HTTPS scheme. - public required Uri Endpoint + /// + /// This can be omitted when constructing the transport with an that has a + /// . An explicitly configured endpoint takes precedence over the client base address. + /// + public Uri? Endpoint { get; set { - if (value is null) - { - throw new ArgumentNullException(nameof(value), "Endpoint cannot be null."); - } - if (!value.IsAbsoluteUri) + if (value is not null && !value.IsAbsoluteUri) { throw new ArgumentException("Endpoint must be an absolute URI.", nameof(value)); } - if (value.Scheme != Uri.UriSchemeHttp && value.Scheme != Uri.UriSchemeHttps) + if (value is not null && value.Scheme != Uri.UriSchemeHttp && value.Scheme != Uri.UriSchemeHttps) { throw new ArgumentException("Endpoint must use HTTP or HTTPS scheme.", nameof(value)); } diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs index fb918989b..319d9b15d 100644 --- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs @@ -30,6 +30,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase /// public SseClientSessionTransport( string endpointName, + Uri endpoint, HttpClientTransportOptions transportOptions, McpHttpClient httpClient, Channel? messageChannel, @@ -40,7 +41,7 @@ public SseClientSessionTransport( Throw.IfNull(httpClient); _options = transportOptions; - _sseEndpoint = transportOptions.Endpoint; + _sseEndpoint = endpoint; _httpClient = httpClient; _connectionCts = new CancellationTokenSource(); _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; @@ -265,4 +266,4 @@ private void HandleEndpointEvent(string data) [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} rejected SSE transport POST for message ID '{MessageId}'. Server response: '{responseContent}'.")] private partial void LogRejectedPostSensitive(string endpointName, string messageId, string responseContent); -} \ No newline at end of file +} diff --git a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs index a6334aa31..c440f0903 100644 --- a/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs @@ -20,6 +20,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa private static readonly MediaTypeWithQualityHeaderValue s_textEventStreamMediaType = new("text/event-stream"); private readonly McpHttpClient _httpClient; + private readonly Uri _endpoint; private readonly HttpClientTransportOptions _options; private readonly CancellationTokenSource _connectionCts = new(); private readonly ILogger _logger; @@ -34,6 +35,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa public StreamableHttpClientSessionTransport( string endpointName, + Uri endpoint, HttpClientTransportOptions transportOptions, McpHttpClient httpClient, Channel? messageChannel, @@ -43,6 +45,7 @@ public StreamableHttpClientSessionTransport( Throw.IfNull(transportOptions); Throw.IfNull(httpClient); + _endpoint = endpoint; _options = transportOptions; _httpClient = httpClient; _logger = (ILogger?)loggerFactory?.CreateLogger() ?? NullLogger.Instance; @@ -155,7 +158,7 @@ internal async Task SendHttpRequestAsync(JsonRpcMessage mes using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token); cancellationToken = sendCts.Token; - using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _options.Endpoint) + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _endpoint) { Headers = { @@ -377,7 +380,7 @@ await SendGetSseRequestWithRetriesAsync( } shouldDelay = true; - using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint); + using var request = new HttpRequestMessage(HttpMethod.Get, _endpoint); request.Headers.Accept.Add(s_textEventStreamMediaType); CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion, state.LastEventId); @@ -516,7 +519,7 @@ message is JsonRpcMessageWithId rpcResponseOrError && private async Task SendDeleteRequest() { - using var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, _options.Endpoint); + using var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, _endpoint); CopyAdditionalHeaders(deleteRequest.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion); // Do not validate we get a successful status code, because server support for the DELETE request is optional diff --git a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs index a203797b7..cd94f5af8 100644 --- a/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs +++ b/tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs @@ -40,6 +40,75 @@ public void Constructor_Throws_For_Null_HttpClient() Assert.Equal("httpClient", exception.ParamName); } + [Fact] + public void Constructor_Throws_When_No_Endpoint_Is_Available() + { + var options = new HttpClientTransportOptions(); + using var httpClient = new HttpClient(); + + var exception = Assert.Throws(() => new HttpClientTransport(options, httpClient, LoggerFactory)); + + Assert.Equal("transportOptions", exception.ParamName); + Assert.Contains(nameof(HttpClient.BaseAddress), exception.Message); + } + + [Fact] + public async Task ConnectAsync_Uses_Injected_HttpClient_BaseAddress_When_Endpoint_Is_Omitted() + { + var options = new HttpClientTransportOptions + { + TransportMode = HttpTransportMode.Sse, + }; + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler) + { + BaseAddress = new Uri("https+http://mcp-server/sse"), + }; + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = request => + { + Assert.Equal(httpClient.BaseAddress, request.RequestUri); + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("event: endpoint\r\ndata: /messages\r\n\r\n"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + Assert.NotNull(session); + } + + [Fact] + public async Task ConnectAsync_Prefers_Explicit_Endpoint_Over_HttpClient_BaseAddress() + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri("https://explicit.example/sse"), + TransportMode = HttpTransportMode.Sse, + }; + using var mockHttpHandler = new MockHttpHandler(); + using var httpClient = new HttpClient(mockHttpHandler) + { + BaseAddress = new Uri("https://base-address.example/sse"), + }; + await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory); + + mockHttpHandler.RequestHandler = request => + { + Assert.Equal(options.Endpoint, request.RequestUri); + return Task.FromResult(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("event: endpoint\r\ndata: /messages\r\n\r\n"), + }); + }; + + await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken); + Assert.NotNull(session); + } + [Fact] public async Task ConnectAsync_Should_Connect_Successfully() {