Bring up to 0.1.13 conformance (#1254)
This commit is contained in:
parent
9c1538fd98
commit
27b5eb8031
18 changed files with 1249 additions and 78 deletions
|
|
@ -44,6 +44,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
|
|
||||||
private string? _clientId;
|
private string? _clientId;
|
||||||
private string? _clientSecret;
|
private string? _clientSecret;
|
||||||
|
private string? _tokenEndpointAuthMethod;
|
||||||
private ITokenCache _tokenCache;
|
private ITokenCache _tokenCache;
|
||||||
private AuthorizationServerMetadata? _authServerMetadata;
|
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
|
// Store auth server metadata for future refresh operations
|
||||||
_authServerMetadata = authServerMetadata;
|
_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)
|
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",
|
["grant_type"] = "refresh_token",
|
||||||
["refresh_token"] = refreshToken,
|
["refresh_token"] = refreshToken,
|
||||||
["client_id"] = GetClientIdOrThrow(),
|
|
||||||
["client_secret"] = _clientSecret ?? string.Empty,
|
|
||||||
["resource"] = resourceUri.ToString(),
|
["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);
|
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!httpResponse.IsSuccessStatusCode)
|
if (!httpResponse.IsSuccessStatusCode)
|
||||||
|
|
@ -482,22 +481,17 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
{
|
{
|
||||||
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
var resourceUri = GetRequiredResourceUri(protectedResourceMetadata);
|
||||||
|
|
||||||
var requestContent = new FormUrlEncodedContent(new Dictionary<string, string>
|
Dictionary<string, string> formFields = new()
|
||||||
{
|
{
|
||||||
["grant_type"] = "authorization_code",
|
["grant_type"] = "authorization_code",
|
||||||
["code"] = authorizationCode,
|
["code"] = authorizationCode,
|
||||||
["redirect_uri"] = _redirectUri.ToString(),
|
["redirect_uri"] = _redirectUri.ToString(),
|
||||||
["client_id"] = GetClientIdOrThrow(),
|
|
||||||
["code_verifier"] = codeVerifier,
|
["code_verifier"] = codeVerifier,
|
||||||
["client_secret"] = _clientSecret ?? string.Empty,
|
|
||||||
["resource"] = resourceUri.ToString(),
|
["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);
|
using var httpResponse = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
await httpResponse.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
|
await httpResponse.EnsureSuccessStatusCodeWithResponseBodyAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|
@ -506,6 +500,38 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
return tokens.AccessToken;
|
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)
|
private async Task<TokenContainer> HandleSuccessfulTokenResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
@ -620,6 +646,11 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient
|
||||||
_clientSecret = registrationResponse.ClientSecret;
|
_clientSecret = registrationResponse.ClientSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(registrationResponse.TokenEndpointAuthMethod))
|
||||||
|
{
|
||||||
|
_tokenEndpointAuthMethod = registrationResponse.TokenEndpointAuthMethod;
|
||||||
|
}
|
||||||
|
|
||||||
LogDynamicClientRegistrationSuccessful(_clientId!);
|
LogDynamicClientRegistrationSuccessful(_clientId!);
|
||||||
|
|
||||||
if (_dcrResponseDelegate is not null)
|
if (_dcrResponseDelegate is not null)
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,7 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
async ct =>
|
async ct =>
|
||||||
{
|
{
|
||||||
var result = await elicitationHandler(request, ct).ConfigureAwait(false);
|
var result = await elicitationHandler(request, ct).ConfigureAwait(false);
|
||||||
|
result = ElicitResult.WithDefaults(request, result);
|
||||||
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult);
|
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.ElicitResult);
|
||||||
},
|
},
|
||||||
options.SendTaskStatusNotifications,
|
options.SendTaskStatusNotifications,
|
||||||
|
|
@ -205,6 +206,7 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
|
|
||||||
// Normal synchronous execution - serialize result to JsonElement
|
// Normal synchronous execution - serialize result to JsonElement
|
||||||
var elicitResult = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
|
var elicitResult = await elicitationHandler(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
elicitResult = ElicitResult.WithDefaults(request, elicitResult);
|
||||||
return JsonSerializer.SerializeToElement(elicitResult, McpJsonUtilities.JsonContext.Default.ElicitResult);
|
return JsonSerializer.SerializeToElement(elicitResult, McpJsonUtilities.JsonContext.Default.ElicitResult);
|
||||||
},
|
},
|
||||||
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
||||||
|
|
@ -214,7 +216,11 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
{
|
{
|
||||||
requestHandlers.Set(
|
requestHandlers.Set(
|
||||||
RequestMethods.ElicitationCreate,
|
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.ElicitRequestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ElicitResult);
|
McpJsonUtilities.JsonContext.Default.ElicitResult);
|
||||||
}
|
}
|
||||||
|
|
@ -671,4 +677,5 @@ internal sealed partial class McpClientImpl : McpClient
|
||||||
|
|
||||||
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} client resumed existing session.")]
|
||||||
private partial void LogClientSessionResumed(string endpointName);
|
private partial void LogClientSessionResumed(string endpointName);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.ServerSentEvents;
|
using System.Net.ServerSentEvents;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
@ -239,7 +240,19 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
if (shouldDelay)
|
if (shouldDelay)
|
||||||
{
|
{
|
||||||
var delay = state.RetryInterval ?? _options.DefaultReconnectionInterval;
|
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;
|
shouldDelay = true;
|
||||||
|
|
||||||
|
|
@ -336,9 +349,11 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is IOException or HttpRequestException)
|
catch (Exception ex) when (ex is IOException or HttpRequestException)
|
||||||
{
|
{
|
||||||
|
state.StreamEndedTimestamp = Stopwatch.GetTimestamp();
|
||||||
return new() { IsNetworkError = true };
|
return new() { IsNetworkError = true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.StreamEndedTimestamp = Stopwatch.GetTimestamp();
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,6 +450,8 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
{
|
{
|
||||||
public string? LastEventId { get; set; }
|
public string? LastEventId { get; set; }
|
||||||
public TimeSpan? RetryInterval { 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>
|
/// <summary>
|
||||||
|
|
@ -445,4 +462,13 @@ internal sealed partial class StreamableHttpClientSessionTransport : TransportBa
|
||||||
public JsonRpcMessageWithId? Response { get; init; }
|
public JsonRpcMessageWithId? Response { get; init; }
|
||||||
public bool IsNetworkError { 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>
|
/// <summary>Gets or sets the type of the schema.</summary>
|
||||||
[JsonPropertyName("type")]
|
[JsonPropertyName("type")]
|
||||||
public abstract string Type { get; set; }
|
public abstract string Type { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,36 @@ public sealed class ElicitResult : Result
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[JsonPropertyName("content")]
|
[JsonPropertyName("content")]
|
||||||
public IDictionary<string, JsonElement>? Content { get; set; }
|
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>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -309,13 +309,15 @@ public abstract partial class McpServer : McpSession
|
||||||
Throw.IfNull(requestParams);
|
Throw.IfNull(requestParams);
|
||||||
ThrowIfElicitationUnsupported(requestParams);
|
ThrowIfElicitationUnsupported(requestParams);
|
||||||
|
|
||||||
return await SendRequestWithTaskStatusTrackingAsync(
|
var result = await SendRequestWithTaskStatusTrackingAsync(
|
||||||
RequestMethods.ElicitationCreate,
|
RequestMethods.ElicitationCreate,
|
||||||
requestParams,
|
requestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
McpJsonUtilities.JsonContext.Default.ElicitRequestParams,
|
||||||
McpJsonUtilities.JsonContext.Default.ElicitResult,
|
McpJsonUtilities.JsonContext.Default.ElicitResult,
|
||||||
"Waiting for user input",
|
"Waiting for user input",
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return ElicitResult.WithDefaults(requestParams, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -83,10 +83,11 @@ public static class NodeHelpers
|
||||||
var repoRoot = FindRepoRoot();
|
var repoRoot = FindRepoRoot();
|
||||||
var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance");
|
var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance");
|
||||||
|
|
||||||
|
ProcessStartInfo startInfo;
|
||||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
{
|
{
|
||||||
// On Windows, node_modules/.bin contains .cmd shims that can be executed directly
|
// On Windows, node_modules/.bin contains .cmd shims that can be executed directly
|
||||||
return new ProcessStartInfo
|
startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = $"{binPath}.cmd",
|
FileName = $"{binPath}.cmd",
|
||||||
Arguments = arguments,
|
Arguments = arguments,
|
||||||
|
|
@ -98,7 +99,7 @@ public static class NodeHelpers
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return new ProcessStartInfo
|
startInfo = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = binPath,
|
FileName = binPath,
|
||||||
Arguments = arguments,
|
Arguments = arguments,
|
||||||
|
|
@ -108,6 +109,21 @@ public static class NodeHelpers
|
||||||
CreateNoWindow = true
|
CreateNoWindow = true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On macOS, disable .NET mini-dump file generation for child processes. When
|
||||||
|
// dotnet test runs with --blame-crash, it sets DOTNET_DbgEnableMiniDump=1 in the
|
||||||
|
// environment. This is inherited by grandchild .NET processes (e.g. ConformanceClient
|
||||||
|
// launched via node). On macOS, the createdump tool can hang indefinitely due to
|
||||||
|
// ptrace/SIP restrictions, causing the entire test run to hang. Disabling mini-dumps
|
||||||
|
// only suppresses the dump file creation; the runtime still prints crash diagnostics
|
||||||
|
// (stack traces, signal info, etc.) to stderr, which the test captures.
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
{
|
||||||
|
startInfo.Environment["DOTNET_DbgEnableMiniDump"] = "0";
|
||||||
|
startInfo.Environment["COMPlus_DbgEnableMiniDump"] = "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return startInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,32 @@ public class ClientConformanceTests
|
||||||
[Theory(Skip = "Node.js is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNodeInstalled))]
|
[Theory(Skip = "Node.js is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNodeInstalled))]
|
||||||
[InlineData("initialize")]
|
[InlineData("initialize")]
|
||||||
[InlineData("tools_call")]
|
[InlineData("tools_call")]
|
||||||
|
[InlineData("elicitation-sep1034-client-defaults")]
|
||||||
|
[InlineData("sse-retry")]
|
||||||
[InlineData("auth/metadata-default")]
|
[InlineData("auth/metadata-default")]
|
||||||
[InlineData("auth/metadata-var1")]
|
[InlineData("auth/metadata-var1")]
|
||||||
[InlineData("auth/metadata-var2")]
|
[InlineData("auth/metadata-var2")]
|
||||||
[InlineData("auth/metadata-var3")]
|
[InlineData("auth/metadata-var3")]
|
||||||
[InlineData("auth/basic-cimd")]
|
[InlineData("auth/basic-cimd")]
|
||||||
// [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
|
|
||||||
// [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
|
|
||||||
[InlineData("auth/scope-from-www-authenticate")]
|
[InlineData("auth/scope-from-www-authenticate")]
|
||||||
[InlineData("auth/scope-from-scopes-supported")]
|
[InlineData("auth/scope-from-scopes-supported")]
|
||||||
[InlineData("auth/scope-omitted-when-undefined")]
|
[InlineData("auth/scope-omitted-when-undefined")]
|
||||||
[InlineData("auth/scope-step-up")]
|
[InlineData("auth/scope-step-up")]
|
||||||
|
[InlineData("auth/scope-retry-limit")]
|
||||||
|
[InlineData("auth/token-endpoint-auth-basic")]
|
||||||
|
[InlineData("auth/token-endpoint-auth-post")]
|
||||||
|
[InlineData("auth/token-endpoint-auth-none")]
|
||||||
|
[InlineData("auth/resource-mismatch")]
|
||||||
|
[InlineData("auth/pre-registration")]
|
||||||
|
|
||||||
|
// Backcompat: Legacy 2025-03-26 OAuth flows (no PRM, root-location metadata) we don't implement.
|
||||||
|
// [InlineData("auth/2025-03-26-oauth-metadata-backcompat")]
|
||||||
|
// [InlineData("auth/2025-03-26-oauth-endpoint-fallback")]
|
||||||
|
|
||||||
|
// Extensions: Require ES256 JWT signing (private_key_jwt) and client_credentials grant support.
|
||||||
|
// [InlineData("auth/client-credentials-jwt")]
|
||||||
|
// [InlineData("auth/client-credentials-basic")]
|
||||||
|
|
||||||
public async Task RunConformanceTest(string scenario)
|
public async Task RunConformanceTest(string scenario)
|
||||||
{
|
{
|
||||||
// Run the conformance test suite
|
// Run the conformance test suite
|
||||||
|
|
@ -88,7 +103,20 @@ public class ClientConformanceTests
|
||||||
process.BeginOutputReadLine();
|
process.BeginOutputReadLine();
|
||||||
process.BeginErrorReadLine();
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
await process.WaitForExitAsync();
|
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(cts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
return (
|
||||||
|
Success: false,
|
||||||
|
Output: outputBuilder.ToString(),
|
||||||
|
Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Success: process.ExitCode == 0,
|
Success: process.ExitCode == 0,
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ using ModelContextProtocol.Tests.Utils;
|
||||||
namespace ModelContextProtocol.ConformanceTests;
|
namespace ModelContextProtocol.ConformanceTests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs the official MCP conformance tests against the ConformanceServer.
|
/// Shared fixture that starts a single ConformanceServer instance for all tests in
|
||||||
/// This test starts the ConformanceServer, runs the Node.js-based conformance test suite,
|
/// <see cref="ServerConformanceTests"/>. This avoids TCP port TIME_WAIT conflicts
|
||||||
/// and reports the results.
|
/// that occur when each test starts and stops its own server on the same port.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ServerConformanceTests : IAsyncLifetime
|
public class ConformanceServerFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
// Use different ports for each target framework to allow parallel execution
|
// Use different ports for each target framework to allow parallel execution
|
||||||
// net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003
|
// net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003
|
||||||
|
|
@ -27,22 +27,16 @@ public class ServerConformanceTests : IAsyncLifetime
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly int _serverPort = GetPortForTargetFramework();
|
|
||||||
private readonly string _serverUrl;
|
|
||||||
private readonly ITestOutputHelper _output;
|
|
||||||
private Task? _serverTask;
|
private Task? _serverTask;
|
||||||
private CancellationTokenSource? _serverCts;
|
private CancellationTokenSource? _serverCts;
|
||||||
|
|
||||||
public ServerConformanceTests(ITestOutputHelper output)
|
public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}";
|
||||||
{
|
|
||||||
_output = output;
|
|
||||||
_serverUrl = $"http://localhost:{_serverPort}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask InitializeAsync()
|
public async ValueTask InitializeAsync()
|
||||||
{
|
{
|
||||||
// Start the ConformanceServer
|
_serverCts = new CancellationTokenSource();
|
||||||
StartConformanceServer();
|
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(
|
||||||
|
["--urls", ServerUrl], cancellationToken: _serverCts.Token));
|
||||||
|
|
||||||
// Wait for server to be ready (retry for up to 30 seconds)
|
// Wait for server to be ready (retry for up to 30 seconds)
|
||||||
var timeout = TimeSpan.FromSeconds(30);
|
var timeout = TimeSpan.FromSeconds(30);
|
||||||
|
|
@ -53,9 +47,7 @@ public class ServerConformanceTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Try to connect to the health endpoint
|
await httpClient.GetAsync($"{ServerUrl}/health");
|
||||||
await httpClient.GetAsync($"{_serverUrl}/health");
|
|
||||||
// Any response (even an error) means the server is up
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (HttpRequestException)
|
catch (HttpRequestException)
|
||||||
|
|
@ -75,7 +67,6 @@ public class ServerConformanceTests : IAsyncLifetime
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
// Stop the server
|
|
||||||
if (_serverCts != null)
|
if (_serverCts != null)
|
||||||
{
|
{
|
||||||
_serverCts.Cancel();
|
_serverCts.Cancel();
|
||||||
|
|
@ -93,30 +84,52 @@ public class ServerConformanceTests : IAsyncLifetime
|
||||||
_serverCts.Dispose();
|
_serverCts.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the official MCP conformance tests against the ConformanceServer.
|
||||||
|
/// Uses a shared <see cref="ConformanceServerFixture"/> so the server is started once
|
||||||
|
/// and reused across all tests, avoiding TCP port conflicts on Windows.
|
||||||
|
/// </summary>
|
||||||
|
public class ServerConformanceTests(ConformanceServerFixture fixture, ITestOutputHelper output)
|
||||||
|
: IClassFixture<ConformanceServerFixture>
|
||||||
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunConformanceTests()
|
public async Task RunConformanceTests()
|
||||||
{
|
{
|
||||||
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||||
|
|
||||||
// Run the conformance test suite
|
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl}");
|
||||||
var result = await RunConformanceTestsAsync();
|
|
||||||
|
|
||||||
// Report the results
|
|
||||||
Assert.True(result.Success,
|
Assert.True(result.Success,
|
||||||
$"Conformance tests failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
$"Conformance tests failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StartConformanceServer()
|
[Fact]
|
||||||
|
public async Task RunPendingConformanceTest_JsonSchema202012()
|
||||||
{
|
{
|
||||||
// Start the server in a background task
|
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||||
_serverCts = new CancellationTokenSource();
|
|
||||||
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token));
|
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario json-schema-2020-12");
|
||||||
|
|
||||||
|
Assert.True(result.Success,
|
||||||
|
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync()
|
[Fact]
|
||||||
|
public async Task RunPendingConformanceTest_ServerSsePolling()
|
||||||
{
|
{
|
||||||
var startInfo = NodeHelpers.ConformanceTestStartInfo($"server --url {_serverUrl}");
|
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
|
||||||
|
|
||||||
|
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl} --scenario server-sse-polling");
|
||||||
|
|
||||||
|
Assert.True(result.Success,
|
||||||
|
$"Conformance test failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments)
|
||||||
|
{
|
||||||
|
var startInfo = NodeHelpers.ConformanceTestStartInfo(arguments);
|
||||||
|
|
||||||
var outputBuilder = new StringBuilder();
|
var outputBuilder = new StringBuilder();
|
||||||
var errorBuilder = new StringBuilder();
|
var errorBuilder = new StringBuilder();
|
||||||
|
|
@ -127,7 +140,7 @@ public class ServerConformanceTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
if (e.Data != null)
|
if (e.Data != null)
|
||||||
{
|
{
|
||||||
_output.WriteLine(e.Data);
|
output.WriteLine(e.Data);
|
||||||
outputBuilder.AppendLine(e.Data);
|
outputBuilder.AppendLine(e.Data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -136,7 +149,7 @@ public class ServerConformanceTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
if (e.Data != null)
|
if (e.Data != null)
|
||||||
{
|
{
|
||||||
_output.WriteLine(e.Data);
|
output.WriteLine(e.Data);
|
||||||
errorBuilder.AppendLine(e.Data);
|
errorBuilder.AppendLine(e.Data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -145,7 +158,20 @@ public class ServerConformanceTests : IAsyncLifetime
|
||||||
process.BeginOutputReadLine();
|
process.BeginOutputReadLine();
|
||||||
process.BeginErrorReadLine();
|
process.BeginErrorReadLine();
|
||||||
|
|
||||||
await process.WaitForExitAsync();
|
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(cts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
process.Kill(entireProcessTree: true);
|
||||||
|
return (
|
||||||
|
Success: false,
|
||||||
|
Output: outputBuilder.ToString(),
|
||||||
|
Error: errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Success: process.ExitCode == 0,
|
Success: process.ExitCode == 0,
|
||||||
|
|
|
||||||
|
|
@ -470,11 +470,23 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
||||||
await CallInitializeAndValidateAsync();
|
await CallInitializeAndValidateAsync();
|
||||||
await CallEchoAndValidateAsync();
|
await CallEchoAndValidateAsync();
|
||||||
|
|
||||||
// Add 5 seconds to idle timeout to account for the interval of the PeriodicTimer.
|
// The background IdleTrackingBackgroundService prunes sessions asynchronously after
|
||||||
fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
|
// the PeriodicTimer (5s interval) tick fires. We advance past the 2-hour idle timeout
|
||||||
|
// then poll until the session returns NotFound. Each HTTP POST also refreshes the
|
||||||
|
// session's LastActivityTicks via AcquireReferenceAsync, so we must re-advance time
|
||||||
|
// each iteration to ensure the session appears idle again for the next prune pass.
|
||||||
|
var deadline = DateTime.UtcNow + TestConstants.DefaultTimeout;
|
||||||
|
HttpStatusCode statusCode;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
|
||||||
|
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||||
|
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
|
||||||
|
statusCode = response.StatusCode;
|
||||||
|
}
|
||||||
|
while (statusCode != HttpStatusCode.NotFound && DateTime.UtcNow < deadline);
|
||||||
|
|
||||||
using var response = await HttpClient.PostAsync("", JsonContent(EchoRequest), TestContext.Current.CancellationToken);
|
Assert.Equal(HttpStatusCode.NotFound, statusCode);
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Text;
|
using System.Text.Json;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using ModelContextProtocol.Client;
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
|
||||||
// This program expects the following command-line arguments:
|
// This program expects the following command-line arguments:
|
||||||
// 1. The client conformance test scenario to run (e.g., "tools_call")
|
// 1. The client conformance test scenario to run (e.g., "tools_call")
|
||||||
|
|
@ -24,7 +25,15 @@ McpClientOptions options = new()
|
||||||
{
|
{
|
||||||
Name = "ConformanceClient",
|
Name = "ConformanceClient",
|
||||||
Version = "1.0.0"
|
Version = "1.0.0"
|
||||||
}
|
},
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (request, ct) =>
|
||||||
|
{
|
||||||
|
// Accept with empty content; the SDK applies schema defaults automatically.
|
||||||
|
return new ValueTask<ElicitResult>(new ElicitResult { Action = "accept", Content = new Dictionary<string, System.Text.Json.JsonElement>() });
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
var consoleLoggerFactory = LoggerFactory.Create(builder =>
|
var consoleLoggerFactory = LoggerFactory.Create(builder =>
|
||||||
|
|
@ -50,21 +59,50 @@ if (callbackPort == 0)
|
||||||
|
|
||||||
var clientRedirectUri = new Uri($"http://localhost:{callbackPort}/callback");
|
var clientRedirectUri = new Uri($"http://localhost:{callbackPort}/callback");
|
||||||
|
|
||||||
|
// Read conformance context for scenarios that provide additional data (e.g., pre-registered credentials).
|
||||||
|
string? preRegisteredClientId = null;
|
||||||
|
string? preRegisteredClientSecret = null;
|
||||||
|
var conformanceContext = Environment.GetEnvironmentVariable("MCP_CONFORMANCE_CONTEXT");
|
||||||
|
if (!string.IsNullOrEmpty(conformanceContext))
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(conformanceContext);
|
||||||
|
if (doc.RootElement.TryGetProperty("client_id", out var clientIdEl))
|
||||||
|
{
|
||||||
|
preRegisteredClientId = clientIdEl.GetString();
|
||||||
|
}
|
||||||
|
if (doc.RootElement.TryGetProperty("client_secret", out var clientSecretEl))
|
||||||
|
{
|
||||||
|
preRegisteredClientSecret = clientSecretEl.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var oauthOptions = new ModelContextProtocol.Authentication.ClientOAuthOptions
|
||||||
|
{
|
||||||
|
RedirectUri = clientRedirectUri,
|
||||||
|
// Configure the metadata document URI for CIMD.
|
||||||
|
ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"),
|
||||||
|
AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (preRegisteredClientId is not null)
|
||||||
|
{
|
||||||
|
// Use pre-registered credentials instead of DCR.
|
||||||
|
oauthOptions.ClientId = preRegisteredClientId;
|
||||||
|
oauthOptions.ClientSecret = preRegisteredClientSecret;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
oauthOptions.DynamicClientRegistration = new()
|
||||||
|
{
|
||||||
|
ClientName = "ProtectedMcpClient",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
var clientTransport = new HttpClientTransport(new()
|
var clientTransport = new HttpClientTransport(new()
|
||||||
{
|
{
|
||||||
Endpoint = new Uri(endpoint),
|
Endpoint = new Uri(endpoint),
|
||||||
TransportMode = HttpTransportMode.StreamableHttp,
|
TransportMode = HttpTransportMode.StreamableHttp,
|
||||||
OAuth = new()
|
OAuth = oauthOptions,
|
||||||
{
|
|
||||||
RedirectUri = clientRedirectUri,
|
|
||||||
// Configure the metadata document URI for CIMD.
|
|
||||||
ClientMetadataDocumentUri = new Uri("https://conformance-test.local/client-metadata.json"),
|
|
||||||
AuthorizationRedirectDelegate = (authUrl, redirectUri, ct) => HandleAuthorizationUrlAsync(authUrl, redirectUri, ct),
|
|
||||||
DynamicClientRegistration = new()
|
|
||||||
{
|
|
||||||
ClientName = "ProtectedMcpClient",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}, loggerFactory: consoleLoggerFactory);
|
}, loggerFactory: consoleLoggerFactory);
|
||||||
|
|
||||||
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
|
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
|
||||||
|
|
@ -89,6 +127,26 @@ switch (scenario)
|
||||||
success &= !(result.IsError == true);
|
success &= !(result.IsError == true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "elicitation-sep1034-client-defaults":
|
||||||
|
{
|
||||||
|
var tools = await mcpClient.ListToolsAsync();
|
||||||
|
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||||
|
var toolName = "test_client_elicitation_defaults";
|
||||||
|
Console.WriteLine($"Calling tool: {toolName}");
|
||||||
|
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
||||||
|
success &= !(result.IsError == true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "sse-retry":
|
||||||
|
{
|
||||||
|
var tools = await mcpClient.ListToolsAsync();
|
||||||
|
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||||
|
var toolName = "test_reconnection";
|
||||||
|
Console.WriteLine($"Calling tool: {toolName}");
|
||||||
|
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>());
|
||||||
|
success &= !(result.IsError == true);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "auth/scope-step-up":
|
case "auth/scope-step-up":
|
||||||
{
|
{
|
||||||
// Just testing that we can authenticate and list tools
|
// Just testing that we can authenticate and list tools
|
||||||
|
|
@ -96,7 +154,7 @@ switch (scenario)
|
||||||
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
Console.WriteLine($"Available tools: {string.Join(", ", tools.Select(t => t.Name))}");
|
||||||
|
|
||||||
// Call the "test_tool" tool
|
// Call the "test_tool" tool
|
||||||
var toolName = tools.FirstOrDefault()?.Name ?? "test-tool";
|
var toolName = "test-tool";
|
||||||
Console.WriteLine($"Calling tool: {toolName}");
|
Console.WriteLine($"Calling tool: {toolName}");
|
||||||
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
|
|
@ -105,6 +163,20 @@ switch (scenario)
|
||||||
success &= !(result.IsError == true);
|
success &= !(result.IsError == true);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "auth/scope-retry-limit":
|
||||||
|
{
|
||||||
|
// Try to list tools - this triggers the auth flow that always fails with 403.
|
||||||
|
// The test validates the client doesn't retry indefinitely.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await mcpClient.ListToolsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Expected auth failure: {ex.Message}");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// No extra processing for other scenarios
|
// No extra processing for other scenarios
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
using ConformanceServer.Prompts;
|
using ConformanceServer.Prompts;
|
||||||
using ConformanceServer.Resources;
|
using ConformanceServer.Resources;
|
||||||
using ConformanceServer.Tools;
|
using ConformanceServer.Tools;
|
||||||
|
using Microsoft.Extensions.Caching.Distributed;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using ModelContextProtocol.Protocol;
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
|
|
@ -26,8 +30,28 @@ public class Program
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddMcpServer()
|
.AddMcpServer()
|
||||||
.WithHttpTransport()
|
.WithHttpTransport(options =>
|
||||||
|
{
|
||||||
|
// Enable resumability for SSE polling conformance test
|
||||||
|
options.EventStreamStore = new DistributedCacheEventStreamStore(
|
||||||
|
new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())));
|
||||||
|
})
|
||||||
.WithTools<ConformanceTools>()
|
.WithTools<ConformanceTools>()
|
||||||
|
.WithTools([ConformanceTools.CreateJsonSchema202012Tool()])
|
||||||
|
.AddCallToolFilter(next => async (request, cancellationToken) =>
|
||||||
|
{
|
||||||
|
var result = await next(request, cancellationToken);
|
||||||
|
|
||||||
|
// For the test_reconnection tool, enable polling mode after the tool runs.
|
||||||
|
// This stores the result and closes the SSE stream, so the client
|
||||||
|
// must reconnect via GET with Last-Event-ID to retrieve the result.
|
||||||
|
if (request.Params?.Name == "test_reconnection")
|
||||||
|
{
|
||||||
|
await request.EnablePollingAsync(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
})
|
||||||
.WithPrompts<ConformancePrompts>()
|
.WithPrompts<ConformancePrompts>()
|
||||||
.WithResources<ConformanceResources>()
|
.WithResources<ConformanceResources>()
|
||||||
.WithSubscribeToResourcesHandler(async (ctx, ct) =>
|
.WithSubscribeToResourcesHandler(async (ctx, ct) =>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ using ModelContextProtocol.Protocol;
|
||||||
using ModelContextProtocol.Server;
|
using ModelContextProtocol.Server;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace ConformanceServer.Tools;
|
namespace ConformanceServer.Tools;
|
||||||
|
|
||||||
|
|
@ -399,4 +398,48 @@ public class ConformanceTools
|
||||||
return $"Elicitation not supported or error: {ex.Message}";
|
return $"Elicitation not supported or error: {ex.Message}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Create the json_schema_2020_12_tool with a raw JSON Schema 2020-12 inputSchema.</summary>
|
||||||
|
public static McpServerTool CreateJsonSchema202012Tool()
|
||||||
|
{
|
||||||
|
var tool = McpServerTool.Create(
|
||||||
|
() => "JSON Schema 2020-12 tool executed successfully",
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Name = "json_schema_2020_12_tool",
|
||||||
|
Description = "Tool with JSON Schema 2020-12 features"
|
||||||
|
});
|
||||||
|
|
||||||
|
tool.ProtocolTool.InputSchema = JsonElement.Parse("""
|
||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"$defs": {
|
||||||
|
"address": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"street": { "type": "string" },
|
||||||
|
"city": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"address": { "$ref": "#/$defs/address" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
return tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool(Name = "test_reconnection")]
|
||||||
|
[Description("Tests SSE stream reconnection by closing the stream mid-call")]
|
||||||
|
public static string TestReconnection()
|
||||||
|
{
|
||||||
|
// This tool doesn't need to do anything - the call filter will close the stream after this tool runs,
|
||||||
|
// and the client must reconnect to get the result.
|
||||||
|
return "Reconnection test completed successfully";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -411,7 +411,7 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
|
||||||
|
|
||||||
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var extraMessage = await extraNotificationReceived.Task.WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
|
var extraMessage = await extraNotificationReceived.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal("extra", extraMessage);
|
Assert.Equal("extra", extraMessage);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Tests.Utils;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Tests.Protocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests verifying that the client applies elicitation schema defaults independently of the server.
|
||||||
|
/// Uses a custom transport that acts as a server, sending elicitation requests and capturing
|
||||||
|
/// the raw response the client sends back, before any server-side default application.
|
||||||
|
/// </summary>
|
||||||
|
public class ElicitationClientDefaultsTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
||||||
|
{
|
||||||
|
private static readonly ElicitRequestParams s_elicitParamsWithDefaults = new()
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = new()
|
||||||
|
{
|
||||||
|
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
|
||||||
|
{
|
||||||
|
["name"] = new ElicitRequestParams.StringSchema { Description = "Name", Default = "John Doe" },
|
||||||
|
["age"] = new ElicitRequestParams.NumberSchema { Type = "integer", Description = "Age", Default = 30 },
|
||||||
|
["score"] = new ElicitRequestParams.NumberSchema { Description = "Score", Default = 95.5 },
|
||||||
|
["active"] = new ElicitRequestParams.BooleanSchema { Description = "Active", Default = true },
|
||||||
|
["status"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema
|
||||||
|
{
|
||||||
|
Description = "Status",
|
||||||
|
Enum = ["active", "inactive", "pending"],
|
||||||
|
Default = "active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAppliesDefaults_NullContent()
|
||||||
|
{
|
||||||
|
// Client handler returns accept with null content.
|
||||||
|
// The client should fill in all defaults before sending the response.
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||||
|
{
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (_, _) =>
|
||||||
|
new(new ElicitResult { Action = "accept", Content = null }),
|
||||||
|
},
|
||||||
|
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
|
||||||
|
|
||||||
|
Assert.Equal("accept", rawResult.Action);
|
||||||
|
Assert.NotNull(rawResult.Content);
|
||||||
|
Assert.Equal(5, rawResult.Content.Count);
|
||||||
|
Assert.Equal("John Doe", rawResult.Content["name"].GetString());
|
||||||
|
Assert.Equal(30, rawResult.Content["age"].GetDouble());
|
||||||
|
Assert.Equal(95.5, rawResult.Content["score"].GetDouble());
|
||||||
|
Assert.True(rawResult.Content["active"].GetBoolean());
|
||||||
|
Assert.Equal("active", rawResult.Content["status"].GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAppliesDefaults_EmptyContent()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||||
|
{
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (_, _) =>
|
||||||
|
new(new ElicitResult { Action = "accept", Content = new Dictionary<string, JsonElement>() }),
|
||||||
|
},
|
||||||
|
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
|
||||||
|
|
||||||
|
Assert.Equal("accept", rawResult.Action);
|
||||||
|
Assert.NotNull(rawResult.Content);
|
||||||
|
Assert.Equal(5, rawResult.Content.Count);
|
||||||
|
Assert.Equal("John Doe", rawResult.Content["name"].GetString());
|
||||||
|
Assert.Equal(30, rawResult.Content["age"].GetDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAppliesDefaults_PartialContent()
|
||||||
|
{
|
||||||
|
// Client handler returns accept with only some fields.
|
||||||
|
// The client should fill in missing defaults before sending the response.
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||||
|
{
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (_, _) =>
|
||||||
|
new(new ElicitResult
|
||||||
|
{
|
||||||
|
Action = "accept",
|
||||||
|
Content = new Dictionary<string, JsonElement>
|
||||||
|
{
|
||||||
|
["name"] = JsonElement.Parse("\"Alice\""),
|
||||||
|
["active"] = JsonElement.Parse("false"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
|
||||||
|
|
||||||
|
Assert.Equal("accept", rawResult.Action);
|
||||||
|
Assert.NotNull(rawResult.Content);
|
||||||
|
Assert.Equal(5, rawResult.Content.Count);
|
||||||
|
|
||||||
|
// User-provided values preserved
|
||||||
|
Assert.Equal("Alice", rawResult.Content["name"].GetString());
|
||||||
|
Assert.False(rawResult.Content["active"].GetBoolean());
|
||||||
|
|
||||||
|
// Missing fields filled with client-side defaults
|
||||||
|
Assert.Equal(30, rawResult.Content["age"].GetDouble());
|
||||||
|
Assert.Equal(95.5, rawResult.Content["score"].GetDouble());
|
||||||
|
Assert.Equal("active", rawResult.Content["status"].GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAppliesDefaults_AllFieldsProvided_NoChange()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||||
|
{
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (_, _) =>
|
||||||
|
new(new ElicitResult
|
||||||
|
{
|
||||||
|
Action = "accept",
|
||||||
|
Content = new Dictionary<string, JsonElement>
|
||||||
|
{
|
||||||
|
["name"] = JsonElement.Parse("\"Alice\""),
|
||||||
|
["age"] = JsonElement.Parse("25"),
|
||||||
|
["score"] = JsonElement.Parse("88.0"),
|
||||||
|
["active"] = JsonElement.Parse("false"),
|
||||||
|
["status"] = JsonElement.Parse("\"inactive\""),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
|
||||||
|
|
||||||
|
Assert.NotNull(rawResult.Content);
|
||||||
|
Assert.Equal(5, rawResult.Content.Count);
|
||||||
|
Assert.Equal("Alice", rawResult.Content["name"].GetString());
|
||||||
|
Assert.Equal(25, rawResult.Content["age"].GetDouble());
|
||||||
|
Assert.Equal(88.0, rawResult.Content["score"].GetDouble());
|
||||||
|
Assert.False(rawResult.Content["active"].GetBoolean());
|
||||||
|
Assert.Equal("inactive", rawResult.Content["status"].GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAppliesDefaults_Decline_NoDefaultsApplied()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||||
|
{
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (_, _) =>
|
||||||
|
new(new ElicitResult { Action = "decline" }),
|
||||||
|
},
|
||||||
|
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
|
||||||
|
|
||||||
|
Assert.Equal("decline", rawResult.Action);
|
||||||
|
Assert.Null(rawResult.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAppliesDefaults_Cancel_NoDefaultsApplied()
|
||||||
|
{
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using ClientElicitationTestTransport transport = new(s_elicitParamsWithDefaults);
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||||
|
{
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (_, _) => new(new ElicitResult { Action = "cancel" }),
|
||||||
|
},
|
||||||
|
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
|
||||||
|
|
||||||
|
Assert.Equal("cancel", rawResult.Action);
|
||||||
|
Assert.Null(rawResult.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAppliesDefaults_SchemaWithNoDefaults_NoChange()
|
||||||
|
{
|
||||||
|
ElicitRequestParams paramsNoDefaults = new()
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = new()
|
||||||
|
{
|
||||||
|
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
|
||||||
|
{
|
||||||
|
["name"] = new ElicitRequestParams.StringSchema { Description = "Name" },
|
||||||
|
["age"] = new ElicitRequestParams.NumberSchema { Type = "integer", Description = "Age" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using ClientElicitationTestTransport transport = new(paramsNoDefaults);
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||||
|
{
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (_, _) =>
|
||||||
|
new(new ElicitResult { Action = "accept", Content = new Dictionary<string, JsonElement>() }),
|
||||||
|
},
|
||||||
|
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
|
||||||
|
|
||||||
|
Assert.True(rawResult.IsAccepted);
|
||||||
|
Assert.NotNull(rawResult.Content);
|
||||||
|
Assert.Empty(rawResult.Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAppliesDefaults_MultiSelectEnum()
|
||||||
|
{
|
||||||
|
ElicitRequestParams paramsMultiSelect = new()
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = new()
|
||||||
|
{
|
||||||
|
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
|
||||||
|
{
|
||||||
|
["tags"] = new ElicitRequestParams.UntitledMultiSelectEnumSchema
|
||||||
|
{
|
||||||
|
Description = "Tags",
|
||||||
|
Items = new ElicitRequestParams.UntitledEnumItemsSchema { Enum = ["a", "b", "c"] },
|
||||||
|
Default = ["a", "c"],
|
||||||
|
},
|
||||||
|
["categories"] = new ElicitRequestParams.TitledMultiSelectEnumSchema
|
||||||
|
{
|
||||||
|
Description = "Categories",
|
||||||
|
Items = new ElicitRequestParams.TitledEnumItemsSchema
|
||||||
|
{
|
||||||
|
AnyOf =
|
||||||
|
[
|
||||||
|
new ElicitRequestParams.EnumSchemaOption { Const = "x", Title = "X" },
|
||||||
|
new ElicitRequestParams.EnumSchemaOption { Const = "y", Title = "Y" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Default = ["y"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using ClientElicitationTestTransport transport = new(paramsMultiSelect);
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||||
|
{
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (_, _) =>
|
||||||
|
new(new ElicitResult { Action = "accept", Content = new Dictionary<string, JsonElement>() }),
|
||||||
|
},
|
||||||
|
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
|
||||||
|
|
||||||
|
Assert.NotNull(rawResult.Content);
|
||||||
|
Assert.Equal(2, rawResult.Content.Count);
|
||||||
|
|
||||||
|
var tags = rawResult.Content["tags"].EnumerateArray().Select(e => e.GetString()!).ToArray();
|
||||||
|
Assert.Equal(["a", "c"], tags);
|
||||||
|
|
||||||
|
var categories = rawResult.Content["categories"].EnumerateArray().Select(e => e.GetString()!).ToArray();
|
||||||
|
Assert.Equal(["y"], categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClientAppliesDefaults_TitledSingleSelectEnum()
|
||||||
|
{
|
||||||
|
ElicitRequestParams paramsTitledEnum = new()
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = new()
|
||||||
|
{
|
||||||
|
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
|
||||||
|
{
|
||||||
|
["priority"] = new ElicitRequestParams.TitledSingleSelectEnumSchema
|
||||||
|
{
|
||||||
|
Description = "Priority",
|
||||||
|
OneOf =
|
||||||
|
[
|
||||||
|
new ElicitRequestParams.EnumSchemaOption { Const = "low", Title = "Low" },
|
||||||
|
new ElicitRequestParams.EnumSchemaOption { Const = "high", Title = "High" },
|
||||||
|
],
|
||||||
|
Default = "low",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var ct = TestContext.Current.CancellationToken;
|
||||||
|
await using ClientElicitationTestTransport transport = new(paramsTitledEnum);
|
||||||
|
await using var client = await McpClient.CreateAsync(transport, new McpClientOptions
|
||||||
|
{
|
||||||
|
Handlers = new()
|
||||||
|
{
|
||||||
|
ElicitationHandler = (_, _) =>
|
||||||
|
new(new ElicitResult { Action = "accept", Content = null }),
|
||||||
|
},
|
||||||
|
}, loggerFactory: LoggerFactory, cancellationToken: ct);
|
||||||
|
|
||||||
|
var rawResult = await transport.SendElicitationAndGetRawResponseAsync(ct);
|
||||||
|
|
||||||
|
Assert.NotNull(rawResult.Content);
|
||||||
|
Assert.Single(rawResult.Content);
|
||||||
|
Assert.Equal("low", rawResult.Content["priority"].GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal transport that acts as a server, sending elicitation requests to the client
|
||||||
|
/// and capturing the raw response, bypassing any server-side default application.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ClientElicitationTestTransport(ElicitRequestParams elicitParams) : IClientTransport
|
||||||
|
{
|
||||||
|
private readonly Channel<JsonRpcMessage> _incomingToClient = Channel.CreateUnbounded<JsonRpcMessage>();
|
||||||
|
private readonly TaskCompletionSource<ElicitResult> _elicitResultTcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private int _nextRequestId;
|
||||||
|
|
||||||
|
public string Name => "test-elicitation-transport";
|
||||||
|
|
||||||
|
public Task<ITransport> ConnectAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ITransport transport = new TransportChannel(_incomingToClient, this);
|
||||||
|
return Task.FromResult(transport);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ElicitResult> SendElicitationAndGetRawResponseAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestId = new RequestId(Interlocked.Increment(ref _nextRequestId).ToString());
|
||||||
|
await _incomingToClient.Writer.WriteAsync(new JsonRpcRequest
|
||||||
|
{
|
||||||
|
Id = requestId,
|
||||||
|
Method = RequestMethods.ElicitationCreate,
|
||||||
|
Params = JsonSerializer.SerializeToNode(elicitParams, McpJsonUtilities.DefaultOptions),
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return await _elicitResultTcs.Task.WaitAsync(TestConstants.DefaultTimeout, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleOutgoingMessage(JsonRpcMessage message)
|
||||||
|
{
|
||||||
|
if (message is JsonRpcRequest { Method: RequestMethods.Initialize } initReq)
|
||||||
|
{
|
||||||
|
// Respond to initialize
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await _incomingToClient.Writer.WriteAsync(new JsonRpcResponse
|
||||||
|
{
|
||||||
|
Id = initReq.Id,
|
||||||
|
Result = JsonSerializer.SerializeToNode(new InitializeResult
|
||||||
|
{
|
||||||
|
ProtocolVersion = "2025-03-26",
|
||||||
|
Capabilities = new ServerCapabilities(),
|
||||||
|
ServerInfo = new Implementation { Name = "test-server", Version = "1.0.0" },
|
||||||
|
}, McpJsonUtilities.DefaultOptions),
|
||||||
|
}, CancellationToken.None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (message is JsonRpcResponse response)
|
||||||
|
{
|
||||||
|
// Capture the raw elicitation response from the client
|
||||||
|
if (response.Result is { } resultNode &&
|
||||||
|
JsonSerializer.Deserialize<ElicitResult>(resultNode, McpJsonUtilities.DefaultOptions) is { } result)
|
||||||
|
{
|
||||||
|
_elicitResultTcs.TrySetResult(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => default;
|
||||||
|
|
||||||
|
private sealed class TransportChannel(
|
||||||
|
Channel<JsonRpcMessage> incoming,
|
||||||
|
ClientElicitationTestTransport parent) : ITransport
|
||||||
|
{
|
||||||
|
public ChannelReader<JsonRpcMessage> MessageReader => incoming.Reader;
|
||||||
|
public bool IsConnected { get; private set; } = true;
|
||||||
|
public string? SessionId => null;
|
||||||
|
|
||||||
|
public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
parent.HandleOutgoingMessage(message);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
incoming.Writer.TryComplete();
|
||||||
|
IsConnected = false;
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using ModelContextProtocol.Tests.Utils;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Tests.Protocol;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests verifying that the server applies elicitation schema defaults as defense-in-depth,
|
||||||
|
/// independent of the client. Uses a custom transport to bypass client-side default application.
|
||||||
|
/// </summary>
|
||||||
|
public class ElicitationServerDefaultsTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
|
||||||
|
{
|
||||||
|
private static ElicitRequestParams.RequestSchema s_schemaWithDefaults => new()
|
||||||
|
{
|
||||||
|
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
|
||||||
|
{
|
||||||
|
["name"] = new ElicitRequestParams.StringSchema { Description = "Name", Default = "John Doe" },
|
||||||
|
["age"] = new ElicitRequestParams.NumberSchema { Type = "integer", Description = "Age", Default = 30 },
|
||||||
|
["score"] = new ElicitRequestParams.NumberSchema { Description = "Score", Default = 95.5 },
|
||||||
|
["active"] = new ElicitRequestParams.BooleanSchema { Description = "Active", Default = true },
|
||||||
|
["status"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema
|
||||||
|
{
|
||||||
|
Description = "Status",
|
||||||
|
Enum = ["active", "inactive", "pending"],
|
||||||
|
Default = "active",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerDefenseInDepth_NullContent_AppliesDefaults()
|
||||||
|
{
|
||||||
|
// Simulate a client that returns accept with null content (no defaults applied).
|
||||||
|
await using var transport = new ElicitationTestTransport(
|
||||||
|
new ElicitResult { Action = "accept", Content = null });
|
||||||
|
|
||||||
|
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||||
|
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||||
|
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
await transport.InitializeAsync();
|
||||||
|
|
||||||
|
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = s_schemaWithDefaults,
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsAccepted);
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
Assert.Equal(5, result.Content.Count);
|
||||||
|
Assert.Equal("John Doe", result.Content["name"].GetString());
|
||||||
|
Assert.Equal(30, result.Content["age"].GetDouble());
|
||||||
|
Assert.Equal(95.5, result.Content["score"].GetDouble());
|
||||||
|
Assert.True(result.Content["active"].GetBoolean());
|
||||||
|
Assert.Equal("active", result.Content["status"].GetString());
|
||||||
|
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerDefenseInDepth_EmptyContent_AppliesDefaults()
|
||||||
|
{
|
||||||
|
await using var transport = new ElicitationTestTransport(
|
||||||
|
new ElicitResult { Action = "accept", Content = new Dictionary<string, JsonElement>() });
|
||||||
|
|
||||||
|
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||||
|
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||||
|
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
await transport.InitializeAsync();
|
||||||
|
|
||||||
|
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = s_schemaWithDefaults,
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsAccepted);
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
Assert.Equal(5, result.Content.Count);
|
||||||
|
Assert.Equal("John Doe", result.Content["name"].GetString());
|
||||||
|
Assert.Equal(30, result.Content["age"].GetDouble());
|
||||||
|
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerDefenseInDepth_PartialContent_FillsMissing()
|
||||||
|
{
|
||||||
|
// Simulate a client returning accept with only some fields, without applying defaults.
|
||||||
|
await using var transport = new ElicitationTestTransport(
|
||||||
|
new ElicitResult
|
||||||
|
{
|
||||||
|
Action = "accept",
|
||||||
|
Content = new Dictionary<string, JsonElement>
|
||||||
|
{
|
||||||
|
["name"] = JsonElement.Parse("\"Alice\""),
|
||||||
|
["active"] = JsonElement.Parse("true"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||||
|
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||||
|
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
await transport.InitializeAsync();
|
||||||
|
|
||||||
|
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = s_schemaWithDefaults,
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsAccepted);
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
Assert.Equal(5, result.Content.Count);
|
||||||
|
|
||||||
|
// User-provided values preserved
|
||||||
|
Assert.Equal("Alice", result.Content["name"].GetString());
|
||||||
|
Assert.True(result.Content["active"].GetBoolean());
|
||||||
|
|
||||||
|
// Missing fields filled with server-side defaults
|
||||||
|
Assert.Equal(30, result.Content["age"].GetDouble());
|
||||||
|
Assert.Equal(95.5, result.Content["score"].GetDouble());
|
||||||
|
Assert.Equal("active", result.Content["status"].GetString());
|
||||||
|
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerDefenseInDepth_AllFieldsProvided_NoChange()
|
||||||
|
{
|
||||||
|
await using var transport = new ElicitationTestTransport(
|
||||||
|
new ElicitResult
|
||||||
|
{
|
||||||
|
Action = "accept",
|
||||||
|
Content = new Dictionary<string, JsonElement>
|
||||||
|
{
|
||||||
|
["name"] = JsonElement.Parse("\"Alice\""),
|
||||||
|
["age"] = JsonElement.Parse("25"),
|
||||||
|
["score"] = JsonElement.Parse("88.0"),
|
||||||
|
["active"] = JsonElement.Parse("false"),
|
||||||
|
["status"] = JsonElement.Parse("\"inactive\""),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||||
|
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||||
|
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
await transport.InitializeAsync();
|
||||||
|
|
||||||
|
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = s_schemaWithDefaults,
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
Assert.Equal(5, result.Content.Count);
|
||||||
|
Assert.Equal("Alice", result.Content["name"].GetString());
|
||||||
|
Assert.Equal(25, result.Content["age"].GetDouble());
|
||||||
|
Assert.Equal(88.0, result.Content["score"].GetDouble());
|
||||||
|
Assert.False(result.Content["active"].GetBoolean());
|
||||||
|
Assert.Equal("inactive", result.Content["status"].GetString());
|
||||||
|
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerDefenseInDepth_Decline_NoDefaultsApplied()
|
||||||
|
{
|
||||||
|
await using var transport = new ElicitationTestTransport(
|
||||||
|
new ElicitResult { Action = "decline" });
|
||||||
|
|
||||||
|
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||||
|
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||||
|
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
await transport.InitializeAsync();
|
||||||
|
|
||||||
|
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = s_schemaWithDefaults,
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsAccepted);
|
||||||
|
Assert.Null(result.Content);
|
||||||
|
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerDefenseInDepth_Cancel_NoDefaultsApplied()
|
||||||
|
{
|
||||||
|
await using var transport = new ElicitationTestTransport(
|
||||||
|
new ElicitResult { Action = "cancel" });
|
||||||
|
|
||||||
|
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||||
|
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||||
|
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
await transport.InitializeAsync();
|
||||||
|
|
||||||
|
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = s_schemaWithDefaults,
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.IsAccepted);
|
||||||
|
Assert.Null(result.Content);
|
||||||
|
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerDefenseInDepth_SchemaWithNoDefaults_NoChange()
|
||||||
|
{
|
||||||
|
await using var transport = new ElicitationTestTransport(
|
||||||
|
new ElicitResult { Action = "accept", Content = new Dictionary<string, JsonElement>() });
|
||||||
|
|
||||||
|
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||||
|
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||||
|
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
await transport.InitializeAsync();
|
||||||
|
|
||||||
|
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = new()
|
||||||
|
{
|
||||||
|
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
|
||||||
|
{
|
||||||
|
["name"] = new ElicitRequestParams.StringSchema { Description = "Name" },
|
||||||
|
["age"] = new ElicitRequestParams.NumberSchema { Type = "integer", Description = "Age" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.IsAccepted);
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
Assert.Empty(result.Content);
|
||||||
|
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerDefenseInDepth_MultiSelectEnum()
|
||||||
|
{
|
||||||
|
await using var transport = new ElicitationTestTransport(
|
||||||
|
new ElicitResult { Action = "accept", Content = new Dictionary<string, JsonElement>() });
|
||||||
|
|
||||||
|
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||||
|
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||||
|
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
await transport.InitializeAsync();
|
||||||
|
|
||||||
|
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = new()
|
||||||
|
{
|
||||||
|
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
|
||||||
|
{
|
||||||
|
["tags"] = new ElicitRequestParams.UntitledMultiSelectEnumSchema
|
||||||
|
{
|
||||||
|
Description = "Tags",
|
||||||
|
Items = new ElicitRequestParams.UntitledEnumItemsSchema { Enum = ["a", "b", "c"] },
|
||||||
|
Default = ["a", "c"],
|
||||||
|
},
|
||||||
|
["categories"] = new ElicitRequestParams.TitledMultiSelectEnumSchema
|
||||||
|
{
|
||||||
|
Description = "Categories",
|
||||||
|
Items = new ElicitRequestParams.TitledEnumItemsSchema
|
||||||
|
{
|
||||||
|
AnyOf =
|
||||||
|
[
|
||||||
|
new ElicitRequestParams.EnumSchemaOption { Const = "x", Title = "X" },
|
||||||
|
new ElicitRequestParams.EnumSchemaOption { Const = "y", Title = "Y" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Default = ["y"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
Assert.Equal(2, result.Content.Count);
|
||||||
|
|
||||||
|
var tags = result.Content["tags"].EnumerateArray().Select(e => e.GetString()!).ToArray();
|
||||||
|
Assert.Equal(["a", "c"], tags);
|
||||||
|
|
||||||
|
var categories = result.Content["categories"].EnumerateArray().Select(e => e.GetString()!).ToArray();
|
||||||
|
Assert.Equal(["y"], categories);
|
||||||
|
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerDefenseInDepth_TitledSingleSelectEnum()
|
||||||
|
{
|
||||||
|
await using var transport = new ElicitationTestTransport(
|
||||||
|
new ElicitResult { Action = "accept", Content = null });
|
||||||
|
|
||||||
|
var options = new McpServerOptions { Capabilities = new() { Tools = new() } };
|
||||||
|
await using var server = McpServer.Create(transport, options, LoggerFactory);
|
||||||
|
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
|
||||||
|
await transport.InitializeAsync();
|
||||||
|
|
||||||
|
var result = await server.ElicitAsync(new ElicitRequestParams
|
||||||
|
{
|
||||||
|
Message = "Provide info",
|
||||||
|
RequestedSchema = new()
|
||||||
|
{
|
||||||
|
Properties = new Dictionary<string, ElicitRequestParams.PrimitiveSchemaDefinition>
|
||||||
|
{
|
||||||
|
["priority"] = new ElicitRequestParams.TitledSingleSelectEnumSchema
|
||||||
|
{
|
||||||
|
Description = "Priority",
|
||||||
|
OneOf =
|
||||||
|
[
|
||||||
|
new ElicitRequestParams.EnumSchemaOption { Const = "low", Title = "Low" },
|
||||||
|
new ElicitRequestParams.EnumSchemaOption { Const = "high", Title = "High" },
|
||||||
|
],
|
||||||
|
Default = "low",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
Assert.Single(result.Content);
|
||||||
|
Assert.Equal("low", result.Content["priority"].GetString());
|
||||||
|
|
||||||
|
await transport.DisposeAsync();
|
||||||
|
await runTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal transport that responds to elicitation requests with a pre-configured result,
|
||||||
|
/// bypassing any client-side default application.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class ElicitationTestTransport(ElicitResult elicitResult) : ITransport
|
||||||
|
{
|
||||||
|
private readonly Channel<JsonRpcMessage> _channel = Channel.CreateUnbounded<JsonRpcMessage>();
|
||||||
|
private TaskCompletionSource<bool>? _initTcs;
|
||||||
|
|
||||||
|
public ChannelReader<JsonRpcMessage> MessageReader => _channel.Reader;
|
||||||
|
public bool IsConnected { get; private set; } = true;
|
||||||
|
public string? SessionId => null;
|
||||||
|
|
||||||
|
public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (message is JsonRpcResponse response && _initTcs is { } tcs && response.Id == new RequestId("init-1"))
|
||||||
|
{
|
||||||
|
tcs.TrySetResult(true);
|
||||||
|
}
|
||||||
|
else if (message is JsonRpcRequest { Method: RequestMethods.ElicitationCreate } req)
|
||||||
|
{
|
||||||
|
// Respond with the pre-configured elicitation result via Task.Run
|
||||||
|
// to allow the server to finish registering the pending request.
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
await _channel.Writer.WriteAsync(new JsonRpcResponse
|
||||||
|
{
|
||||||
|
Id = req.Id,
|
||||||
|
Result = JsonSerializer.SerializeToNode(elicitResult, McpJsonUtilities.DefaultOptions),
|
||||||
|
}, cancellationToken);
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitializeAsync()
|
||||||
|
{
|
||||||
|
_initTcs = new TaskCompletionSource<bool>();
|
||||||
|
await _channel.Writer.WriteAsync(new JsonRpcRequest
|
||||||
|
{
|
||||||
|
Id = new RequestId("init-1"),
|
||||||
|
Method = RequestMethods.Initialize,
|
||||||
|
Params = JsonSerializer.SerializeToNode(new InitializeRequestParams
|
||||||
|
{
|
||||||
|
ProtocolVersion = "2024-11-05",
|
||||||
|
Capabilities = new ClientCapabilities { Elicitation = new() { Form = new() } },
|
||||||
|
ClientInfo = new Implementation { Name = "test-client", Version = "1.0.0" },
|
||||||
|
}, McpJsonUtilities.DefaultOptions),
|
||||||
|
}, CancellationToken.None);
|
||||||
|
await _initTcs.Task.WaitAsync(TestConstants.DefaultTimeout, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_channel.Writer.TryComplete();
|
||||||
|
IsConnected = false;
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,7 +70,9 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
|
||||||
{
|
{
|
||||||
Name = "long-running-tool",
|
Name = "long-running-tool",
|
||||||
Arguments = EmptyArguments(),
|
Arguments = EmptyArguments(),
|
||||||
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMilliseconds(200) }
|
// Use a TTL long enough that thread pool scheduling delays on loaded CI machines
|
||||||
|
// don't cause the CTS to fire before the tool lambda begins executing.
|
||||||
|
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromSeconds(5) }
|
||||||
},
|
},
|
||||||
cancellationToken: TestContext.Current.CancellationToken);
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
|
@ -333,13 +335,16 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase
|
||||||
RegisterMarker("short-ttl");
|
RegisterMarker("short-ttl");
|
||||||
RegisterMarker("long-ttl");
|
RegisterMarker("long-ttl");
|
||||||
|
|
||||||
// Start task with short TTL (200ms)
|
// Start task with short TTL. Use a TTL long enough that thread pool scheduling
|
||||||
|
// delays on loaded CI machines don't cause the CTS to fire before the tool
|
||||||
|
// lambda begins executing (CancelAfter starts counting at task creation, not
|
||||||
|
// when the tool's Task.Run is scheduled).
|
||||||
var shortTtlResult = await client.CallToolAsync(
|
var shortTtlResult = await client.CallToolAsync(
|
||||||
new CallToolRequestParams
|
new CallToolRequestParams
|
||||||
{
|
{
|
||||||
Name = "trackable-tool",
|
Name = "trackable-tool",
|
||||||
Arguments = CreateMarkerArgs("short-ttl"),
|
Arguments = CreateMarkerArgs("short-ttl"),
|
||||||
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMilliseconds(200) }
|
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromSeconds(5) }
|
||||||
},
|
},
|
||||||
cancellationToken: TestContext.Current.CancellationToken);
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,14 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
|
||||||
|
|
||||||
await Assert.ThrowsAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
await Assert.ThrowsAsync<IOException>(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
|
// The stderr reading thread may not have delivered the callback yet
|
||||||
|
// after the IOException is thrown. Poll briefly for it to arrive.
|
||||||
|
var deadline = DateTime.UtcNow + TestConstants.DefaultTimeout;
|
||||||
|
while (Volatile.Read(ref count) == 0 && DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
Assert.InRange(count, 1, int.MaxValue);
|
Assert.InRange(count, 1, int.MaxValue);
|
||||||
Assert.Contains(id, sb.ToString());
|
Assert.Contains(id, sb.ToString());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue