Skip to content

Commit e4eef7d

Browse files
author
Loi Nguyen
committed
Allow HttpClient BaseAddress as transport endpoint
1 parent dfb2205 commit e4eef7d

6 files changed

Lines changed: 101 additions & 23 deletions

File tree

src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,19 @@ namespace ModelContextProtocol.Client;
1414
internal sealed partial class AutoDetectingClientSessionTransport : ITransport
1515
{
1616
private readonly HttpClientTransportOptions _options;
17+
private readonly Uri _endpoint;
1718
private readonly McpHttpClient _httpClient;
1819
private readonly ILoggerFactory? _loggerFactory;
1920
private readonly ILogger _logger;
2021
private readonly string _name;
2122
private readonly Channel<JsonRpcMessage> _messageChannel;
2223

23-
public AutoDetectingClientSessionTransport(string endpointName, HttpClientTransportOptions transportOptions, McpHttpClient httpClient, ILoggerFactory? loggerFactory)
24+
public AutoDetectingClientSessionTransport(string endpointName, Uri endpoint, HttpClientTransportOptions transportOptions, McpHttpClient httpClient, ILoggerFactory? loggerFactory)
2425
{
2526
Throw.IfNull(transportOptions);
2627
Throw.IfNull(httpClient);
2728

29+
_endpoint = endpoint;
2830
_options = transportOptions;
2931
_httpClient = httpClient;
3032
_loggerFactory = loggerFactory;
@@ -62,7 +64,7 @@ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellat
6264
private async Task InitializeAsync(JsonRpcMessage message, CancellationToken cancellationToken)
6365
{
6466
// Try StreamableHttp first
65-
var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
67+
var streamableHttpTransport = new StreamableHttpClientSessionTransport(_name, _endpoint, _options, _httpClient, _messageChannel, _loggerFactory);
6668

6769
try
6870
{
@@ -126,7 +128,7 @@ private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpReque
126128
throw new InvalidOperationException("Streamable HTTP transport is required to resume an existing session.");
127129
}
128130

129-
var sseTransport = new SseClientSessionTransport(_name, _options, _httpClient, _messageChannel, _loggerFactory);
131+
var sseTransport = new SseClientSessionTransport(_name, _endpoint, _options, _httpClient, _messageChannel, _loggerFactory);
130132

131133
try
132134
{
@@ -193,7 +195,6 @@ public async ValueTask DisposeAsync()
193195

194196
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} using SSE transport.")]
195197
private partial void LogUsingSSE(string endpointName);
196-
197198
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} SSE fallback failed after Streamable HTTP also failed; surfacing both errors.")]
198199
private partial void LogSseFallbackFailedAfterStreamableHttp(string endpointName, Exception sseError);
199200
}

src/ModelContextProtocol.Core/Client/HttpClientTransport.cs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public sealed class HttpClientTransport : IClientTransport, IAsyncDisposable
1818
private readonly HttpClientTransportOptions _options;
1919
private readonly McpHttpClient _mcpHttpClient;
2020
private readonly ILoggerFactory? _loggerFactory;
21+
private readonly Uri _endpoint;
2122

2223
private readonly HttpClient? _ownedHttpClient;
2324

@@ -49,11 +50,15 @@ public HttpClientTransport(HttpClientTransportOptions transportOptions, HttpClie
4950

5051
_options = transportOptions;
5152
_loggerFactory = loggerFactory;
52-
Name = transportOptions.Name ?? transportOptions.Endpoint.ToString();
53+
_endpoint = transportOptions.Endpoint ?? httpClient.BaseAddress ??
54+
throw new ArgumentException(
55+
$"Either '{nameof(HttpClientTransportOptions)}.{nameof(HttpClientTransportOptions.Endpoint)}' or '{nameof(HttpClient)}.{nameof(HttpClient.BaseAddress)}' must be set.",
56+
nameof(transportOptions));
57+
Name = transportOptions.Name ?? _endpoint.ToString();
5358

5459
if (transportOptions.OAuth is { } clientOAuthOptions)
5560
{
56-
_mcpHttpClient = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory);
61+
_mcpHttpClient = new ClientOAuthProvider(_endpoint, clientOAuthOptions, httpClient, loggerFactory);
5762
}
5863
else
5964
{
@@ -79,16 +84,16 @@ public async Task<ITransport> ConnectAsync(CancellationToken cancellationToken =
7984

8085
return _options.TransportMode switch
8186
{
82-
HttpTransportMode.AutoDetect => new AutoDetectingClientSessionTransport(Name, _options, _mcpHttpClient, _loggerFactory),
83-
HttpTransportMode.StreamableHttp => new StreamableHttpClientSessionTransport(Name, _options, _mcpHttpClient, messageChannel: null, _loggerFactory),
87+
HttpTransportMode.AutoDetect => new AutoDetectingClientSessionTransport(Name, _endpoint, _options, _mcpHttpClient, _loggerFactory),
88+
HttpTransportMode.StreamableHttp => new StreamableHttpClientSessionTransport(Name, _endpoint, _options, _mcpHttpClient, messageChannel: null, _loggerFactory),
8489
HttpTransportMode.Sse => await ConnectSseTransportAsync(cancellationToken).ConfigureAwait(false),
8590
_ => throw new InvalidOperationException($"Unsupported transport mode: {_options.TransportMode}"),
8691
};
8792
}
8893

8994
private async Task<ITransport> ConnectSseTransportAsync(CancellationToken cancellationToken)
9095
{
91-
var sessionTransport = new SseClientSessionTransport(Name, _options, _mcpHttpClient, messageChannel: null, _loggerFactory);
96+
var sessionTransport = new SseClientSessionTransport(Name, _endpoint, _options, _mcpHttpClient, messageChannel: null, _loggerFactory);
9297

9398
try
9499
{

src/ModelContextProtocol.Core/Client/HttpClientTransportOptions.cs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,23 @@ namespace ModelContextProtocol.Client;
88
public sealed class HttpClientTransportOptions
99
{
1010
/// <summary>
11-
/// Gets or sets the base address of the server for SSE connections.
11+
/// Gets or sets the base address of the server for HTTP connections.
1212
/// </summary>
13-
/// <exception cref="ArgumentNullException">The value is <see langword="null"/>.</exception>
1413
/// <exception cref="ArgumentException">The value is not an absolute URI, or does not use the HTTP or HTTPS scheme.</exception>
15-
public required Uri Endpoint
14+
/// <remarks>
15+
/// This can be omitted when constructing the transport with an <see cref="HttpClient"/> that has a
16+
/// <see cref="HttpClient.BaseAddress"/>. An explicitly configured endpoint takes precedence over the client base address.
17+
/// </remarks>
18+
public Uri? Endpoint
1619
{
1720
get;
1821
set
1922
{
20-
if (value is null)
21-
{
22-
throw new ArgumentNullException(nameof(value), "Endpoint cannot be null.");
23-
}
24-
if (!value.IsAbsoluteUri)
23+
if (value is not null && !value.IsAbsoluteUri)
2524
{
2625
throw new ArgumentException("Endpoint must be an absolute URI.", nameof(value));
2726
}
28-
if (value.Scheme != Uri.UriSchemeHttp && value.Scheme != Uri.UriSchemeHttps)
27+
if (value is not null && value.Scheme != Uri.UriSchemeHttp && value.Scheme != Uri.UriSchemeHttps)
2928
{
3029
throw new ArgumentException("Endpoint must use HTTP or HTTPS scheme.", nameof(value));
3130
}

src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
3030
/// </summary>
3131
public SseClientSessionTransport(
3232
string endpointName,
33+
Uri endpoint,
3334
HttpClientTransportOptions transportOptions,
3435
McpHttpClient httpClient,
3536
Channel<JsonRpcMessage>? messageChannel,
@@ -40,7 +41,7 @@ public SseClientSessionTransport(
4041
Throw.IfNull(httpClient);
4142

4243
_options = transportOptions;
43-
_sseEndpoint = transportOptions.Endpoint;
44+
_sseEndpoint = endpoint;
4445
_httpClient = httpClient;
4546
_connectionCts = new CancellationTokenSource();
4647
_logger = (ILogger?)loggerFactory?.CreateLogger<HttpClientTransport>() ?? NullLogger.Instance;
@@ -265,4 +266,4 @@ private void HandleEndpointEvent(string data)
265266

266267
[LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} rejected SSE transport POST for message ID '{MessageId}'. Server response: '{responseContent}'.")]
267268
private partial void LogRejectedPostSensitive(string endpointName, string messageId, string responseContent);
268-
}
269+
}

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
2020
private static readonly MediaTypeWithQualityHeaderValue s_textEventStreamMediaType = new("text/event-stream");
2121

2222
private readonly McpHttpClient _httpClient;
23+
private readonly Uri _endpoint;
2324
private readonly HttpClientTransportOptions _options;
2425
private readonly CancellationTokenSource _connectionCts = new();
2526
private readonly ILogger _logger;
@@ -34,6 +35,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
3435

3536
public StreamableHttpClientSessionTransport(
3637
string endpointName,
38+
Uri endpoint,
3739
HttpClientTransportOptions transportOptions,
3840
McpHttpClient httpClient,
3941
Channel<JsonRpcMessage>? messageChannel,
@@ -43,6 +45,7 @@ public StreamableHttpClientSessionTransport(
4345
Throw.IfNull(transportOptions);
4446
Throw.IfNull(httpClient);
4547

48+
_endpoint = endpoint;
4649
_options = transportOptions;
4750
_httpClient = httpClient;
4851
_logger = (ILogger?)loggerFactory?.CreateLogger<HttpClientTransport>() ?? NullLogger.Instance;
@@ -155,7 +158,7 @@ internal async Task<HttpResponseMessage> SendHttpRequestAsync(JsonRpcMessage mes
155158
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token);
156159
cancellationToken = sendCts.Token;
157160

158-
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _options.Endpoint)
161+
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _endpoint)
159162
{
160163
Headers =
161164
{
@@ -377,7 +380,7 @@ await SendGetSseRequestWithRetriesAsync(
377380
}
378381
shouldDelay = true;
379382

380-
using var request = new HttpRequestMessage(HttpMethod.Get, _options.Endpoint);
383+
using var request = new HttpRequestMessage(HttpMethod.Get, _endpoint);
381384
request.Headers.Accept.Add(s_textEventStreamMediaType);
382385
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion, state.LastEventId);
383386

@@ -516,7 +519,7 @@ message is JsonRpcMessageWithId rpcResponseOrError &&
516519

517520
private async Task SendDeleteRequest()
518521
{
519-
using var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, _options.Endpoint);
522+
using var deleteRequest = new HttpRequestMessage(HttpMethod.Delete, _endpoint);
520523
CopyAdditionalHeaders(deleteRequest.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
521524

522525
// Do not validate we get a successful status code, because server support for the DELETE request is optional

tests/ModelContextProtocol.Tests/Transport/HttpClientTransportTests.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,75 @@ public void Constructor_Throws_For_Null_HttpClient()
4040
Assert.Equal("httpClient", exception.ParamName);
4141
}
4242

43+
[Fact]
44+
public void Constructor_Throws_When_No_Endpoint_Is_Available()
45+
{
46+
var options = new HttpClientTransportOptions();
47+
using var httpClient = new HttpClient();
48+
49+
var exception = Assert.Throws<ArgumentException>(() => new HttpClientTransport(options, httpClient, LoggerFactory));
50+
51+
Assert.Equal("transportOptions", exception.ParamName);
52+
Assert.Contains(nameof(HttpClient.BaseAddress), exception.Message);
53+
}
54+
55+
[Fact]
56+
public async Task ConnectAsync_Uses_Injected_HttpClient_BaseAddress_When_Endpoint_Is_Omitted()
57+
{
58+
var options = new HttpClientTransportOptions
59+
{
60+
TransportMode = HttpTransportMode.Sse,
61+
};
62+
using var mockHttpHandler = new MockHttpHandler();
63+
using var httpClient = new HttpClient(mockHttpHandler)
64+
{
65+
BaseAddress = new Uri("https+http://mcp-server/sse"),
66+
};
67+
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
68+
69+
mockHttpHandler.RequestHandler = request =>
70+
{
71+
Assert.Equal(httpClient.BaseAddress, request.RequestUri);
72+
return Task.FromResult(new HttpResponseMessage
73+
{
74+
StatusCode = HttpStatusCode.OK,
75+
Content = new StringContent("event: endpoint\r\ndata: /messages\r\n\r\n"),
76+
});
77+
};
78+
79+
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
80+
Assert.NotNull(session);
81+
}
82+
83+
[Fact]
84+
public async Task ConnectAsync_Prefers_Explicit_Endpoint_Over_HttpClient_BaseAddress()
85+
{
86+
var options = new HttpClientTransportOptions
87+
{
88+
Endpoint = new Uri("https://explicit.example/sse"),
89+
TransportMode = HttpTransportMode.Sse,
90+
};
91+
using var mockHttpHandler = new MockHttpHandler();
92+
using var httpClient = new HttpClient(mockHttpHandler)
93+
{
94+
BaseAddress = new Uri("https://base-address.example/sse"),
95+
};
96+
await using var transport = new HttpClientTransport(options, httpClient, LoggerFactory);
97+
98+
mockHttpHandler.RequestHandler = request =>
99+
{
100+
Assert.Equal(options.Endpoint, request.RequestUri);
101+
return Task.FromResult(new HttpResponseMessage
102+
{
103+
StatusCode = HttpStatusCode.OK,
104+
Content = new StringContent("event: endpoint\r\ndata: /messages\r\n\r\n"),
105+
});
106+
};
107+
108+
await using var session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
109+
Assert.NotNull(session);
110+
}
111+
43112
[Fact]
44113
public async Task ConnectAsync_Should_Connect_Successfully()
45114
{

0 commit comments

Comments
 (0)