Bring up to 0.1.13 conformance (#1254)

This commit is contained in:
Stephen Toub 2026-02-10 11:33:52 -05:00 • committed by GitHub
commit 27b5eb8031
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1249 additions and 78 deletions

View file

@ -44,6 +44,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private string? _clientId;
private string? _clientSecret;
private string? _tokenEndpointAuthMethod;
private ITokenCache _tokenCache;
private AuthorizationServerMetadata? _authServerMetadata;
@ -293,6 +294,9 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
}
}
// Determine the token endpoint auth method from server metadata if not already set by DCR.
_tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault();
// Store auth server metadata for future refresh operations
_authServerMetadata = authServerMetadata;
@ -385,20 +389,15 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
private async Task<string?> RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
{
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
Dictionary<string, string> formFields = new()
{
["grant_type"] = "refresh_token",
["refresh_token"] = refreshToken,
["client_id"] = GetClientIdOrThrow(),
["client_secret"] = _clientSecret ?? string.Empty,
["resource"] = resourceUri.ToString(),
});
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
{
Content = requestContent
};
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!httpResponse.IsSuccessStatusCode)
@ -482,22 +481,17 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
{
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
Dictionary<string, string> formFields = new()
{
["grant_type"] = "authorization_code",
["code"] = authorizationCode,
["redirect_uri"] = _redirectUri.ToString(),
["client_id"] = GetClientIdOrThrow(),
["code_verifier"] = codeVerifier,
["client_secret"] = _clientSecret ?? string.Empty,
["resource"] = resourceUri.ToString(),
});
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
{
Content = requestContent
};
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
await httpResponse.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
@ -506,6 +500,38 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
return tokens.AccessToken;
}
/// <summary>
/// Creates an HTTP request to the token endpoint, applying the appropriate authentication
/// method based on <see cref="_tokenEndpointAuthMethod"/>.
/// </summary>
private HttpRequestMessage CreateTokenRequest(Uri tokenEndpoint, Dictionary<string, string> formFields)
{
HttpRequestMessage request = new(HttpMethod.Post, tokenEndpoint);
var clientId = GetClientIdOrThrow();
if (string.Equals(_tokenEndpointAuthMethod, "client_secret_basic", StringComparison.Ordinal))
{
// Per RFC 6749 §2.3.1: send client_id:client_secret as HTTP Basic auth.
request.Headers.Authorization = new(
"Basic",
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Uri.EscapeDataString(clientId)}:{Uri.EscapeDataString(_clientSecret ?? string.Empty)}")));
}
else if (string.Equals(_tokenEndpointAuthMethod, "none", StringComparison.Ordinal))
{
// Public client: include client_id in the body but no secret.
formFields["client_id"] = clientId;
}
else
{
// Default to client_secret_post: include credentials in the body.
formFields["client_id"] = clientId;
formFields["client_secret"] = _clientSecret ?? string.Empty;
}
request.Content = new FormUrlEncodedContent(formFields);
return request;
}
private async Task<TokenContainer> HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
@ -620,6 +646,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
_clientSecret = registrationResponse.ClientSecret;
}
if (!string.IsNullOrEmpty(registrationResponse.TokenEndpointAuthMethod))
{
_tokenEndpointAuthMethod = registrationResponse.TokenEndpointAuthMethod;
}
LogDynamicClientRegistrationSuccessful(_clientId!);
if (_dcrResponseDelegate is not null)

View file

@ -197,6 +197,7 @@ internal sealed partial class McpClientImpl : McpClient
async ct =>
{
var result = await elicitationHandler(request, ct).ConfigureAwait(false);
result = ElicitResult.WithDefaults(request, result);
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult);
},
options.SendTaskStatusNotifications,
@ -205,6 +206,7 @@ internal sealed partial class McpClientImpl : McpClient
// Normal synchronous execution - serialize result to JsonElement
var elicitResult = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
elicitResult = ElicitResult.WithDefaults(request, elicitResult);
return JsonSerializer.SerializeToElement(elicitResult, McpJsonUtilities.JsonContext.Default.ElicitResult);
},
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
@ -214,7 +216,11 @@ internal sealed partial class McpClientImpl : McpClient
{
requestHandlers.Set(
RequestMethods.ElicitationCreate,
(request, _, cancellationToken) => elicitationHandler(request, cancellationToken),
async (request, _, cancellationToken) =>
{
var result = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
return ElicitResult.WithDefaults(request, result);
},
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
McpJsonUtilities.JsonContext.Default.ElicitResult);
}
@ -671,4 +677,5 @@ internal sealed partial class McpClientImpl : McpClient
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")]
private partial void LogClientSessionResumed(string endpointName);
}

View file

@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Net.ServerSentEvents;
using System.Text.Json;
@ -239,7 +240,19 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
if (shouldDelay)
{
var delay = state.RetryInterval ?? _options.DefaultReconnectionInterval;
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
// Subtract time already elapsed since the SSE stream ended to more accurately
// honor the retry interval. Without this, processing overhead (HTTP response
// disposal, condition checks, etc.) inflates the observed reconnection delay.
if (state.StreamEndedTimestamp != 0)
{
delay -= ElapsedSince(state.StreamEndedTimestamp);
}
if (delay > TimeSpan.Zero)
{
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
}
}
shouldDelay = true;
@ -336,9 +349,11 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
}
catch (Exception ex) when (ex is IOException or HttpRequestException)
{
state.StreamEndedTimestamp = Stopwatch.GetTimestamp();
return new() { IsNetworkError = true };
}
state.StreamEndedTimestamp = Stopwatch.GetTimestamp();
return default;
}
@ -435,6 +450,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
{
public string? LastEventId { get; set; }
public TimeSpan? RetryInterval { get; set; }
/// <summary>Timestamp (via Stopwatch.GetTimestamp()) when the last SSE stream ended, used to discount processing overhead from the retry delay.</summary>
public long StreamEndedTimestamp { get; set; }
}
/// <summary>
@ -445,4 +462,13 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
public JsonRpcMessageWithId? Response { get; init; }
public bool IsNetworkError { get; init; }
}
private static TimeSpan ElapsedSince(long stopwatchTimestamp)
{
#if NET
return Stopwatch.GetElapsedTime(stopwatchTimestamp);
#else
return TimeSpan.FromSeconds((double)(Stopwatch.GetTimestamp() - stopwatchTimestamp) / Stopwatch.Frequency);
#endif
}
}

View file

@ -143,6 +143,22 @@ public sealed class ElicitRequestParams : RequestParams
{
}
/// <summary>Gets the default value for this schema as a <see cref="JsonElement"/>, if one is defined.</summary>
internal JsonElement? GetDefaultAsJsonElement() => this switch
{
StringSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
NumberSchema { Default: { } n } => JsonSerializer.SerializeToElement(n, McpJsonUtilities.JsonContext.Default.Double),
BooleanSchema { Default: { } b } => JsonSerializer.SerializeToElement(b, McpJsonUtilities.JsonContext.Default.Boolean),
UntitledSingleSelectEnumSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
TitledSingleSelectEnumSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
UntitledMultiSelectEnumSchema { Default: { } a } => JsonSerializer.SerializeToElement(a, McpJsonUtilities.JsonContext.Default.IListString),
TitledMultiSelectEnumSchema { Default: { } a } => JsonSerializer.SerializeToElement(a, McpJsonUtilities.JsonContext.Default.IListString),
#pragma warning disable MCP9001 // LegacyTitledEnumSchema is deprecated but supported for backward compatibility
LegacyTitledEnumSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
#pragma warning restore MCP9001
_ => null,
};
/// <summary>Gets or sets the type of the schema.</summary>
[JsonPropertyName("type")]
public abstract string Type { get; set; }

View file

@ -56,6 +56,36 @@ public sealed class ElicitResult : Result
/// </remarks>
[JsonPropertyName("content")]
public IDictionary<string, JsonElement>? Content { get; set; }
/// <summary>
/// Applies default values from the elicitation request schema to any missing fields in the result content.
/// </summary>
internal static ElicitResult WithDefaults(ElicitRequestParams? requestParams, ElicitResult result)
{
if (result.IsAccepted && requestParams?.RequestedSchema?.Properties is { } properties)
{
Dictionary<string, JsonElement>? newContent = null;
foreach (KeyValuePair<string, ElicitRequestParams.PrimitiveSchemaDefinition> kvp in properties)
{
if ((result.Content is null || !result.Content.ContainsKey(kvp.Key)) &&
kvp.Value.GetDefaultAsJsonElement() is { } element)
{
newContent ??= result.Content is not null ?
new Dictionary<string, JsonElement>(result.Content) :
[];
newContent[kvp.Key] = element;
}
}
if (newContent is not null)
{
return new ElicitResult { Action = result.Action, Content = newContent, Meta = result.Meta };
}
}
return result;
}
}
/// <summary>

View file

@ -309,13 +309,15 @@ public abstract partial class McpServer : McpSession
Throw.IfNull(requestParams);
ThrowIfElicitationUnsupported(requestParams);
return await SendRequestWithTaskStatusTrackingAsync(
var result = await SendRequestWithTaskStatusTrackingAsync(
RequestMethods.ElicitationCreate,
requestParams,
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
McpJsonUtilities.JsonContext.Default.ElicitResult,
"Waiting for user input",
cancellationToken).ConfigureAwait(false);
return ElicitResult.WithDefaults(requestParams, result);
}
/// <summary>