Authorization Support (Using ASP.NET Core Native AuthN/AuthZ Integration) (#377)

This commit is contained in:
Den Delimarsky 🌺 2025-07-03 14:39:28 -07:00 • committed by GitHub
commit cdf88e7f5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 4207 additions and 126 deletions

View file

@ -13,13 +13,13 @@ namespace ModelContextProtocol.Client;
internal sealed partial class AutoDetectingClientSessionTransport : ITransport
{
private readonly SseClientTransportOptions _options;
private readonly HttpClient _httpClient;
private readonly McpHttpClient _httpClient;
private readonly ILoggerFactory? _loggerFactory;
private readonly ILogger _logger;
private readonly string _name;
private readonly Channel<JsonRpcMessage> _messageChannel;
public AutoDetectingClientSessionTransport(SseClientTransportOptions transportOptions, HttpClient httpClient, ILoggerFactory? loggerFactory, string endpointName)
public AutoDetectingClientSessionTransport(string endpointName, SseClientTransportOptions transportOptions, McpHttpClient httpClient, ILoggerFactory? loggerFactory)
{
Throw.IfNull(transportOptions);
Throw.IfNull(httpClient);

View file

@ -0,0 +1,42 @@
using ModelContextProtocol.Protocol;
using System.Diagnostics;
#if NET
using System.Net.Http.Json;
#else
using System.Text;
using System.Text.Json;
#endif
namespace ModelContextProtocol.Client;
internal class McpHttpClient(HttpClient httpClient)
{
internal virtual async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
{
Debug.Assert(request.Content is null, "The request body should only be supplied as a JsonRpcMessage");
Debug.Assert(message is null || request.Method == HttpMethod.Post, "All messages should be sent in POST requests.");
using var content = CreatePostBodyContent(message);
request.Content = content;
return await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
}
private HttpContent? CreatePostBodyContent(JsonRpcMessage? message)
{
if (message is null)
{
return null;
}
#if NET
return JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
#else
return new StringContent(
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
Encoding.UTF8,
"application/json"
);
#endif
}
}

View file

@ -4,7 +4,6 @@ using ModelContextProtocol.Protocol;
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Net.ServerSentEvents;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
@ -15,7 +14,7 @@ namespace ModelContextProtocol.Client;
/// </summary>
internal sealed partial class SseClientSessionTransport : TransportBase
{
private readonly HttpClient _httpClient;
private readonly McpHttpClient _httpClient;
private readonly SseClientTransportOptions _options;
private readonly Uri _sseEndpoint;
private Uri? _messageEndpoint;
@ -31,7 +30,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
public SseClientSessionTransport(
string endpointName,
SseClientTransportOptions transportOptions,
HttpClient httpClient,
McpHttpClient httpClient,
Channel<JsonRpcMessage>? messageChannel,
ILoggerFactory? loggerFactory)
: base(endpointName, messageChannel, loggerFactory)
@ -74,12 +73,6 @@ internal sealed partial class SseClientSessionTransport : TransportBase
if (_messageEndpoint == null)
throw new InvalidOperationException("Transport not connected");
using var content = new StringContent(
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
Encoding.UTF8,
"application/json"
);
string messageId = "(no id)";
if (message is JsonRpcMessageWithId messageWithId)
@ -87,12 +80,9 @@ internal sealed partial class SseClientSessionTransport : TransportBase
messageId = messageWithId.Id.ToString();
}
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint)
{
Content = content,
};
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _messageEndpoint);
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null);
var response = await _httpClient.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false);
var response = await _httpClient.SendAsync(httpRequestMessage, message, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
@ -154,11 +144,7 @@ internal sealed partial class SseClientSessionTransport : TransportBase
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null);
using var response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken
).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();

View file

@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Authentication;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Client;
@ -15,9 +16,10 @@ namespace ModelContextProtocol.Client;
public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
{
private readonly SseClientTransportOptions _options;
private readonly HttpClient _httpClient;
private readonly McpHttpClient _mcpHttpClient;
private readonly ILoggerFactory? _loggerFactory;
private readonly bool _ownsHttpClient;
private readonly HttpClient? _ownedHttpClient;
/// <summary>
/// Initializes a new instance of the <see cref="SseClientTransport"/> class.
@ -45,10 +47,23 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
Throw.IfNull(httpClient);
_options = transportOptions;
_httpClient = httpClient;
_loggerFactory = loggerFactory;
_ownsHttpClient = ownsHttpClient;
Name = transportOptions.Name ?? transportOptions.Endpoint.ToString();
if (transportOptions.OAuth is { } clientOAuthOptions)
{
var oAuthProvider = new ClientOAuthProvider(_options.Endpoint, clientOAuthOptions, httpClient, loggerFactory);
_mcpHttpClient = new AuthenticatingMcpHttpClient(httpClient, oAuthProvider);
}
else
{
_mcpHttpClient = new(httpClient);
}
if (ownsHttpClient)
{
_ownedHttpClient = httpClient;
}
}
/// <inheritdoc />
@ -59,8 +74,8 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
{
return _options.TransportMode switch
{
HttpTransportMode.AutoDetect => new AutoDetectingClientSessionTransport(_options, _httpClient, _loggerFactory, Name),
HttpTransportMode.StreamableHttp => new StreamableHttpClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory),
HttpTransportMode.AutoDetect => new AutoDetectingClientSessionTransport(Name, _options, _mcpHttpClient, _loggerFactory),
HttpTransportMode.StreamableHttp => new StreamableHttpClientSessionTransport(Name, _options, _mcpHttpClient, messageChannel: null, _loggerFactory),
HttpTransportMode.Sse => await ConnectSseTransportAsync(cancellationToken).ConfigureAwait(false),
_ => throw new InvalidOperationException($"Unsupported transport mode: {_options.TransportMode}"),
};
@ -68,7 +83,7 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
private async Task<ITransport> ConnectSseTransportAsync(CancellationToken cancellationToken)
{
var sessionTransport = new SseClientSessionTransport(Name, _options, _httpClient, messageChannel: null, _loggerFactory);
var sessionTransport = new SseClientSessionTransport(Name, _options, _mcpHttpClient, messageChannel: null, _loggerFactory);
try
{
@ -85,11 +100,7 @@ public sealed class SseClientTransport : IClientTransport, IAsyncDisposable
/// <inheritdoc />
public ValueTask DisposeAsync()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
_ownedHttpClient?.Dispose();
return default;
}
}

View file

@ -1,3 +1,5 @@
using ModelContextProtocol.Authentication;
namespace ModelContextProtocol.Client;
/// <summary>
@ -46,7 +48,7 @@ public sealed class SseClientTransportOptions
public HttpTransportMode TransportMode { get; set; } = HttpTransportMode.AutoDetect;
/// <summary>
/// Gets a transport identifier used for logging purposes.
/// Gets or sets a transport identifier used for logging purposes.
/// </summary>
public string? Name { get; set; }
@ -70,4 +72,9 @@ public sealed class SseClientTransportOptions
/// Use this property to specify custom HTTP headers that should be sent with each request to the server.
/// </remarks>
public IDictionary<string, string>? AdditionalHeaders { get; set; }
/// <summary>
/// Gets sor sets the authorization provider to use for authentication.
/// </summary>
public ClientOAuthOptions? OAuth { get; set; }
}

View file

@ -6,12 +6,6 @@ using System.Text.Json;
using ModelContextProtocol.Protocol;
using System.Threading.Channels;
#if NET
using System.Net.Http.Json;
#else
using System.Text;
#endif
namespace ModelContextProtocol.Client;
/// <summary>
@ -22,7 +16,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
private static readonly MediaTypeWithQualityHeaderValue s_applicationJsonMediaType = new("application/json");
private static readonly MediaTypeWithQualityHeaderValue s_textEventStreamMediaType = new("text/event-stream");
private readonly HttpClient _httpClient;
private readonly McpHttpClient _httpClient;
private readonly SseClientTransportOptions _options;
private readonly CancellationTokenSource _connectionCts;
private readonly ILogger _logger;
@ -36,7 +30,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
public StreamableHttpClientSessionTransport(
string endpointName,
SseClientTransportOptions transportOptions,
HttpClient httpClient,
McpHttpClient httpClient,
Channel<JsonRpcMessage>? messageChannel,
ILoggerFactory? loggerFactory)
: base(endpointName, messageChannel, loggerFactory)
@ -69,19 +63,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
using var sendCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _connectionCts.Token);
cancellationToken = sendCts.Token;
#if NET
using var content = JsonContent.Create(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage);
#else
using var content = new StringContent(
JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage),
Encoding.UTF8,
"application/json"
);
#endif
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Post, _options.Endpoint)
{
Content = content,
Headers =
{
Accept = { s_applicationJsonMediaType, s_textEventStreamMediaType },
@ -90,7 +73,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
CopyAdditionalHeaders(httpRequestMessage.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
var response = await _httpClient.SendAsync(httpRequestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
var response = await _httpClient.SendAsync(httpRequestMessage, message, cancellationToken).ConfigureAwait(false);
// We'll let the caller decide whether to throw or fall back given an unsuccessful response.
if (!response.IsSuccessStatusCode)
@ -192,7 +175,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
request.Headers.Accept.Add(s_textEventStreamMediaType);
CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, SessionId, _negotiatedProtocolVersion);
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _connectionCts.Token).ConfigureAwait(false);
using var response = await _httpClient.SendAsync(request, message: null, _connectionCts.Token).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
@ -261,7 +244,7 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
try
{
// Do not validate we get a successful status code, because server support for the DELETE request is optional
(await _httpClient.SendAsync(deleteRequest, CancellationToken.None).ConfigureAwait(false)).Dispose();
(await _httpClient.SendAsync(deleteRequest, message: null, CancellationToken.None).ConfigureAwait(false)).Dispose();
}
catch (Exception ex)
{