Bring up to 0.1.13 conformance (#1254)
This commit is contained in:
parent
9c1538fd98
commit
27b5eb8031
6 changed files with 1249 additions and 78 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue