Skip to content

Commit 27b5eb8

Browse files
authored
Bring up to 0.1.13 conformance (#1254)
1 parent 9c1538f commit 27b5eb8

18 files changed

Lines changed: 1249 additions & 78 deletions

File tree

src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
4444

4545
private string? _clientId;
4646
private string? _clientSecret;
47+
private string? _tokenEndpointAuthMethod;
4748
private ITokenCache _tokenCache;
4849
private AuthorizationServerMetadata? _authServerMetadata;
4950

@@ -293,6 +294,9 @@ await _tokenCache.GetTokensAsync(cancellationToken).ConfigureAwait(false) is { R
293294
}
294295
}
295296

297+
// Determine the token endpoint auth method from server metadata if not already set by DCR.
298+
_tokenEndpointAuthMethod ??= authServerMetadata.TokenEndpointAuthMethodsSupported?.FirstOrDefault();
299+
296300
// Store auth server metadata for future refresh operations
297301
_authServerMetadata = authServerMetadata;
298302

@@ -385,20 +389,15 @@ private static IEnumerable<Uri> GetWellKnownAuthorizationServerMetadataUris(Uri
385389

386390
private async Task<string?> RefreshTokensAsync(string refreshToken, Uri resourceUri, AuthorizationServerMetadata authServerMetadata, CancellationToken cancellationToken)
387391
{
388-
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
392+
Dictionary<string, string> formFields = new()
389393
{
390394
["grant_type"] = "refresh_token",
391395
["refresh_token"] = refreshToken,
392-
["client_id"] = GetClientIdOrThrow(),
393-
["client_secret"] = _clientSecret ?? string.Empty,
394396
["resource"] = resourceUri.ToString(),
395-
});
396-
397-
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
398-
{
399-
Content = requestContent
400397
};
401398

399+
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
400+
402401
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
403402

404403
if (!httpResponse.IsSuccessStatusCode)
@@ -482,22 +481,17 @@ private async Task<string> ExchangeCodeForTokenAsync(
482481
{
483482
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
484483

485-
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
484+
Dictionary<string, string> formFields = new()
486485
{
487486
["grant_type"] = "authorization_code",
488487
["code"] = authorizationCode,
489488
["redirect_uri"] = _redirectUri.ToString(),
490-
["client_id"] = GetClientIdOrThrow(),
491489
["code_verifier"] = codeVerifier,
492-
["client_secret"] = _clientSecret ?? string.Empty,
493490
["resource"] = resourceUri.ToString(),
494-
});
495-
496-
using var request = new HttpRequestMessage(HttpMethod.Post, authServerMetadata.TokenEndpoint)
497-
{
498-
Content = requestContent
499491
};
500492

493+
using var request = CreateTokenRequest(authServerMetadata.TokenEndpoint, formFields);
494+
501495
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
502496
await httpResponse.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
503497

@@ -506,6 +500,38 @@ private async Task<string> ExchangeCodeForTokenAsync(
506500
return tokens.AccessToken;
507501
}
508502

503+
/// <summary>
504+
/// Creates an HTTP request to the token endpoint, applying the appropriate authentication
505+
/// method based on <see cref="_tokenEndpointAuthMethod"/>.
506+
/// </summary>
507+
private HttpRequestMessage CreateTokenRequest(Uri tokenEndpoint, Dictionary<string, string> formFields)
508+
{
509+
HttpRequestMessage request = new(HttpMethod.Post, tokenEndpoint);
510+
511+
var clientId = GetClientIdOrThrow();
512+
if (string.Equals(_tokenEndpointAuthMethod, "client_secret_basic", StringComparison.Ordinal))
513+
{
514+
// Per RFC 6749 §2.3.1: send client_id:client_secret as HTTP Basic auth.
515+
request.Headers.Authorization = new(
516+
"Basic",
517+
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{Uri.EscapeDataString(clientId)}:{Uri.EscapeDataString(_clientSecret ?? string.Empty)}")));
518+
}
519+
else if (string.Equals(_tokenEndpointAuthMethod, "none", StringComparison.Ordinal))
520+
{
521+
// Public client: include client_id in the body but no secret.
522+
formFields["client_id"] = clientId;
523+
}
524+
else
525+
{
526+
// Default to client_secret_post: include credentials in the body.
527+
formFields["client_id"] = clientId;
528+
formFields["client_secret"] = _clientSecret ?? string.Empty;
529+
}
530+
531+
request.Content = new FormUrlEncodedContent(formFields);
532+
return request;
533+
}
534+
509535
private async Task<TokenContainer> HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
510536
{
511537
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
@@ -620,6 +646,11 @@ private async Task PerformDynamicClientRegistrationAsync(
620646
_clientSecret = registrationResponse.ClientSecret;
621647
}
622648

649+
if (!string.IsNullOrEmpty(registrationResponse.TokenEndpointAuthMethod))
650+
{
651+
_tokenEndpointAuthMethod = registrationResponse.TokenEndpointAuthMethod;
652+
}
653+
623654
LogDynamicClientRegistrationSuccessful(_clientId!);
624655

625656
if (_dcrResponseDelegate is not null)

src/ModelContextProtocol.Core/Client/McpClientImpl.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not
197197
async ct =>
198198
{
199199
var result = await elicitationHandler(request, ct).ConfigureAwait(false);
200+
result = ElicitResult.WithDefaults(request, result);
200201
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult);
201202
},
202203
options.SendTaskStatusNotifications,
@@ -205,6 +206,7 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not
205206

206207
// Normal synchronous execution - serialize result to JsonElement
207208
var elicitResult = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
209+
elicitResult = ElicitResult.WithDefaults(request, elicitResult);
208210
return JsonSerializer.SerializeToElement(elicitResult, McpJsonUtilities.JsonContext.Default.ElicitResult);
209211
},
210212
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
@@ -214,7 +216,11 @@ private void RegisterHandlers(McpClientOptions options, NotificationHandlers not
214216
{
215217
requestHandlers.Set(
216218
RequestMethods.ElicitationCreate,
217-
(request, _, cancellationToken) => elicitationHandler(request, cancellationToken),
219+
async (request, _, cancellationToken) =>
220+
{
221+
var result = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
222+
return ElicitResult.WithDefaults(request, result);
223+
},
218224
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
219225
McpJsonUtilities.JsonContext.Default.ElicitResult);
220226
}
@@ -671,4 +677,5 @@ public override async ValueTask DisposeAsync()
671677

672678
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")]
673679
private partial void LogClientSessionResumed(string endpointName);
680+
674681
}

src/ModelContextProtocol.Core/Client/StreamableHttpClientSessionTransport.cs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Microsoft.Extensions.Logging;
22
using Microsoft.Extensions.Logging.Abstractions;
3+
using System.Diagnostics;
34
using System.Net.Http.Headers;
45
using System.Net.ServerSentEvents;
56
using System.Text.Json;
@@ -239,7 +240,19 @@ await SendGetSseRequestWithRetriesAsync(
239240
if (shouldDelay)
240241
{
241242
var delay = state.RetryInterval ?? _options.DefaultReconnectionInterval;
242-
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
243+
244+
// Subtract time already elapsed since the SSE stream ended to more accurately
245+
// honor the retry interval. Without this, processing overhead (HTTP response
246+
// disposal, condition checks, etc.) inflates the observed reconnection delay.
247+
if (state.StreamEndedTimestamp != 0)
248+
{
249+
delay -= ElapsedSince(state.StreamEndedTimestamp);
250+
}
251+
252+
if (delay > TimeSpan.Zero)
253+
{
254+
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
255+
}
243256
}
244257
shouldDelay = true;
245258

@@ -336,9 +349,11 @@ private async Task<SseResponse> ProcessSseResponseAsync(
336349
}
337350
catch (Exception ex) when (ex is IOException or HttpRequestException)
338351
{
352+
state.StreamEndedTimestamp = Stopwatch.GetTimestamp();
339353
return new() { IsNetworkError = true };
340354
}
341355

356+
state.StreamEndedTimestamp = Stopwatch.GetTimestamp();
342357
return default;
343358
}
344359

@@ -435,6 +450,8 @@ private sealed class SseStreamState
435450
{
436451
public string? LastEventId { get; set; }
437452
public TimeSpan? RetryInterval { get; set; }
453+
/// <summary>Timestamp (via Stopwatch.GetTimestamp()) when the last SSE stream ended, used to discount processing overhead from the retry delay.</summary>
454+
public long StreamEndedTimestamp { get; set; }
438455
}
439456

440457
/// <summary>
@@ -445,4 +462,13 @@ private readonly struct SseResponse
445462
public JsonRpcMessageWithId? Response { get; init; }
446463
public bool IsNetworkError { get; init; }
447464
}
465+
466+
private static TimeSpan ElapsedSince(long stopwatchTimestamp)
467+
{
468+
#if NET
469+
return Stopwatch.GetElapsedTime(stopwatchTimestamp);
470+
#else
471+
return TimeSpan.FromSeconds((double)(Stopwatch.GetTimestamp() - stopwatchTimestamp) / Stopwatch.Frequency);
472+
#endif
473+
}
448474
}

src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,22 @@ protected private PrimitiveSchemaDefinition()
143143
{
144144
}
145145

146+
/// <summary>Gets the default value for this schema as a <see cref="JsonElement"/>, if one is defined.</summary>
147+
internal JsonElement? GetDefaultAsJsonElement() => this switch
148+
{
149+
StringSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
150+
NumberSchema { Default: { } n } => JsonSerializer.SerializeToElement(n, McpJsonUtilities.JsonContext.Default.Double),
151+
BooleanSchema { Default: { } b } => JsonSerializer.SerializeToElement(b, McpJsonUtilities.JsonContext.Default.Boolean),
152+
UntitledSingleSelectEnumSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
153+
TitledSingleSelectEnumSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
154+
UntitledMultiSelectEnumSchema { Default: { } a } => JsonSerializer.SerializeToElement(a, McpJsonUtilities.JsonContext.Default.IListString),
155+
TitledMultiSelectEnumSchema { Default: { } a } => JsonSerializer.SerializeToElement(a, McpJsonUtilities.JsonContext.Default.IListString),
156+
#pragma warning disable MCP9001 // LegacyTitledEnumSchema is deprecated but supported for backward compatibility
157+
LegacyTitledEnumSchema { Default: { } s } => JsonSerializer.SerializeToElement(s, McpJsonUtilities.JsonContext.Default.String),
158+
#pragma warning restore MCP9001
159+
_ => null,
160+
};
161+
146162
/// <summary>Gets or sets the type of the schema.</summary>
147163
[JsonPropertyName("type")]
148164
public abstract string Type { get; set; }

src/ModelContextProtocol.Core/Protocol/ElicitResult.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,36 @@ public sealed class ElicitResult : Result
5656
/// </remarks>
5757
[JsonPropertyName("content")]
5858
public IDictionary<string, JsonElement>? Content { get; set; }
59+
60+
/// <summary>
61+
/// Applies default values from the elicitation request schema to any missing fields in the result content.
62+
/// </summary>
63+
internal static ElicitResult WithDefaults(ElicitRequestParams? requestParams, ElicitResult result)
64+
{
65+
if (result.IsAccepted && requestParams?.RequestedSchema?.Properties is { } properties)
66+
{
67+
Dictionary<string, JsonElement>? newContent = null;
68+
69+
foreach (KeyValuePair<string, ElicitRequestParams.PrimitiveSchemaDefinition> kvp in properties)
70+
{
71+
if ((result.Content is null || !result.Content.ContainsKey(kvp.Key)) &&
72+
kvp.Value.GetDefaultAsJsonElement() is { } element)
73+
{
74+
newContent ??= result.Content is not null ?
75+
new Dictionary<string, JsonElement>(result.Content) :
76+
[];
77+
newContent[kvp.Key] = element;
78+
}
79+
}
80+
81+
if (newContent is not null)
82+
{
83+
return new ElicitResult { Action = result.Action, Content = newContent, Meta = result.Meta };
84+
}
85+
}
86+
87+
return result;
88+
}
5989
}
6090

6191
/// <summary>

src/ModelContextProtocol.Core/Server/McpServer.Methods.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,13 +309,15 @@ public async ValueTask<ElicitResult> ElicitAsync(
309309
Throw.IfNull(requestParams);
310310
ThrowIfElicitationUnsupported(requestParams);
311311

312-
return await SendRequestWithTaskStatusTrackingAsync(
312+
var result = await SendRequestWithTaskStatusTrackingAsync(
313313
RequestMethods.ElicitationCreate,
314314
requestParams,
315315
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
316316
McpJsonUtilities.JsonContext.Default.ElicitResult,
317317
"Waiting for user input",
318318
cancellationToken).ConfigureAwait(false);
319+
320+
return ElicitResult.WithDefaults(requestParams, result);
319321
}
320322

321323
/// <summary>

tests/Common/Utils/NodeHelpers.cs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,11 @@ public static ProcessStartInfo ConformanceTestStartInfo(string arguments)
8383
var repoRoot = FindRepoRoot();
8484
var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance");
8585

86+
ProcessStartInfo startInfo;
8687
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
8788
{
8889
// On Windows, node_modules/.bin contains .cmd shims that can be executed directly
89-
return new ProcessStartInfo
90+
startInfo = new ProcessStartInfo
9091
{
9192
FileName = $"{binPath}.cmd",
9293
Arguments = arguments,
@@ -98,7 +99,7 @@ public static ProcessStartInfo ConformanceTestStartInfo(string arguments)
9899
}
99100
else
100101
{
101-
return new ProcessStartInfo
102+
startInfo = new ProcessStartInfo
102103
{
103104
FileName = binPath,
104105
Arguments = arguments,
@@ -108,6 +109,21 @@ public static ProcessStartInfo ConformanceTestStartInfo(string arguments)
108109
CreateNoWindow = true
109110
};
110111
}
112+
113+
// On macOS, disable .NET mini-dump file generation for child processes. When
114+
// dotnet test runs with --blame-crash, it sets DOTNET_DbgEnableMiniDump=1 in the
115+
// environment. This is inherited by grandchild .NET processes (e.g. ConformanceClient
116+
// launched via node). On macOS, the createdump tool can hang indefinitely due to
117+
// ptrace/SIP restrictions, causing the entire test run to hang. Disabling mini-dumps
118+
// only suppresses the dump file creation; the runtime still prints crash diagnostics
119+
// (stack traces, signal info, etc.) to stderr, which the test captures.
120+
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
121+
{
122+
startInfo.Environment["DOTNET_DbgEnableMiniDump"] = "0";
123+
startInfo.Environment["COMPlus_DbgEnableMiniDump"] = "0";
124+
}
125+
126+
return startInfo;
111127
}
112128

113129
/// <summary>

tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,17 +24,32 @@ public ClientConformanceTests(ITestOutputHelper output)
2424
[Theory(Skip = "Node.js is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNodeInstalled))]
2525
[InlineData("initialize")]
2626
[InlineData("tools_call")]
27+
[InlineData("elicitation-sep1034-client-defaults")]
28+
[InlineData("sse-retry")]
2729
[InlineData("auth/metadata-default")]
2830
[InlineData("auth/metadata-var1")]
2931
[InlineData("auth/metadata-var2")]
3032
[InlineData("auth/metadata-var3")]
3133
[InlineData("auth/basic-cimd")]
32-
// [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
33-
// [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
3434
[InlineData("auth/scope-from-www-authenticate")]
3535
[InlineData("auth/scope-from-scopes-supported")]
3636
[InlineData("auth/scope-omitted-when-undefined")]
3737
[InlineData("auth/scope-step-up")]
38+
[InlineData("auth/scope-retry-limit")]
39+
[InlineData("auth/token-endpoint-auth-basic")]
40+
[InlineData("auth/token-endpoint-auth-post")]
41+
[InlineData("auth/token-endpoint-auth-none")]
42+
[InlineData("auth/resource-mismatch")]
43+
[InlineData("auth/pre-registration")]
44+
45+
// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata) we don't implement.
46+
// [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
47+
// [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
48+
49+
// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
50+
// [InlineData("auth/client-credentials-jwt")]
51+
// [InlineData("auth/client-credentials-basic")]
52+
3853
public async Task RunConformanceTest(string scenario)
3954
{
4055
// Run the conformance test suite
@@ -88,7 +103,20 @@ public async Task RunConformanceTest(string scenario)
88103
process.BeginOutputReadLine();
89104
process.BeginErrorReadLine();
90105

91-
await process.WaitForExitAsync();
106+
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
107+
try
108+
{
109+
await process.WaitForExitAsync(cts.Token);
110+
}
111+
catch (OperationCanceledException)
112+
{
113+
process.Kill(entireProcessTree: true);
114+
return (
115+
Success: false,
116+
Output: outputBuilder.ToString(),
117+
Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
118+
);
119+
}
92120

93121
return (
94122
Success: process.ExitCode == 0,

0 commit comments

Comments
 (0)