Bring up to 0.1.13 conformance (#1254)

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -83,10 +83,11 @@ public static class NodeHelpers
var repoRoot = FindRepoRoot();
var binPath = Path.Combine(repoRoot, "node_modules", ".bin", "conformance");
ProcessStartInfo startInfo;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// On Windows, node_modules/.bin contains .cmd shims that can be executed directly
return new ProcessStartInfo
startInfo = new ProcessStartInfo
{
FileName = $"{binPath}.cmd",
Arguments = arguments,
@ -98,7 +99,7 @@ public static class NodeHelpers
}
else
{
return new ProcessStartInfo
startInfo = new ProcessStartInfo
{
FileName = binPath,
Arguments = arguments,
@ -108,6 +109,21 @@ public static class NodeHelpers
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>

View file

@ -24,17 +24,32 @@ public class ClientConformanceTests
[Theory(Skip = "Node.js is not installed. Skipping client conformance tests.", SkipUnless = nameof(IsNodeInstalled))]
[InlineData("initialize")]
[InlineData("tools_call")]
[InlineData("elicitation-sep1034-client-defaults")]
[InlineData("sse-retry")]
[InlineData("auth/metadata-default")]
[InlineData("auth/metadata-var1")]
[InlineData("auth/metadata-var2")]
[InlineData("auth/metadata-var3")]
[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-scopes-supported")]
[InlineData("auth/scope-omitted-when-undefined")]
[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)
{
// Run the conformance test suite
@ -88,7 +103,20 @@ public class ClientConformanceTests
process.BeginOutputReadLine();
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 (
Success: process.ExitCode == 0,

View file

@ -5,11 +5,11 @@ using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
/// <summary>
/// Runs the official MCP conformance tests against the ConformanceServer.
/// This test starts the ConformanceServer, runs the Node.js-based conformance test suite,
/// and reports the results.
/// Shared fixture that starts a single ConformanceServer instance for all tests in
/// <see cref="ServerConformanceTests"/>. This avoids TCP port TIME_WAIT conflicts
/// that occur when each test starts and stops its own server on the same port.
/// </summary>
public class ServerConformanceTests : IAsyncLifetime
public class ConformanceServerFixture : IAsyncLifetime
{
// Use different ports for each target framework to allow parallel execution
// 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 CancellationTokenSource? _serverCts;
public ServerConformanceTests(ITestOutputHelper output)
{
_output = output;
_serverUrl = $"http://localhost:{_serverPort}";
}
public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}";
public async ValueTask InitializeAsync()
{
// Start the ConformanceServer
StartConformanceServer();
_serverCts = new CancellationTokenSource();
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(
["--urls", ServerUrl], cancellationToken: _serverCts.Token));
// Wait for server to be ready (retry for up to 30 seconds)
var timeout = TimeSpan.FromSeconds(30);
@ -53,9 +47,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
try
{
// Try to connect to the health endpoint
await httpClient.GetAsync($"{_serverUrl}/health");
// Any response (even an error) means the server is up
await httpClient.GetAsync($"{ServerUrl}/health");
return;
}
catch (HttpRequestException)
@ -75,7 +67,6 @@ public class ServerConformanceTests : IAsyncLifetime
public async ValueTask DisposeAsync()
{
// Stop the server
if (_serverCts != null)
{
_serverCts.Cancel();
@ -93,30 +84,52 @@ public class ServerConformanceTests : IAsyncLifetime
_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]
public async Task RunConformanceTests()
{
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
// Run the conformance test suite
var result = await RunConformanceTestsAsync();
var result = await RunConformanceTestsAsync($"server --url {fixture.ServerUrl}");
// Report the results
Assert.True(result.Success,
$"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
_serverCts = new CancellationTokenSource();
_serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token));
Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
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 errorBuilder = new StringBuilder();
@ -127,7 +140,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
if (e.Data != null)
{
_output.WriteLine(e.Data);
output.WriteLine(e.Data);
outputBuilder.AppendLine(e.Data);
}
};
@ -136,7 +149,7 @@ public class ServerConformanceTests : IAsyncLifetime
{
if (e.Data != null)
{
_output.WriteLine(e.Data);
output.WriteLine(e.Data);
errorBuilder.AppendLine(e.Data);
}
};
@ -145,7 +158,20 @@ public class ServerConformanceTests : IAsyncLifetime
process.BeginOutputReadLine();
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 (
Success: process.ExitCode == 0,

View file

@ -470,11 +470,23 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
await CallInitializeAndValidateAsync();
await CallEchoAndValidateAsync();
// Add 5 seconds to idle timeout to account for the interval of the PeriodicTimer.
fakeTimeProvider.Advance(TimeSpan.FromHours(2) + TimeSpan.FromSeconds(5));
// The background IdleTrackingBackgroundService prunes sessions asynchronously after
// 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, response.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, statusCode);
}
[Fact]

View file

@ -1,9 +1,10 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Web;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
// This program expects the following command-line arguments:
// 1. The client conformance test scenario to run (e.g., "tools_call")
@ -24,7 +25,15 @@ McpClientOptions options = new()
{
Name = "ConformanceClient",
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 =>
@ -50,21 +59,50 @@ if (callbackPort == 0)
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()
{
Endpoint = new Uri(endpoint),
TransportMode = HttpTransportMode.StreamableHttp,
OAuth = new()
{
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",
},
}
OAuth = oauthOptions,
}, loggerFactory: consoleLoggerFactory);
await using var mcpClient = await McpClient.CreateAsync(clientTransport, options, loggerFactory: consoleLoggerFactory);
@ -89,6 +127,26 @@ switch (scenario)
success &= !(result.IsError == true);
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":
{
// 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))}");
// Call the "test_tool" tool
var toolName = tools.FirstOrDefault()?.Name ?? "test-tool";
var toolName = "test-tool";
Console.WriteLine($"Calling tool: {toolName}");
var result = await mcpClient.CallToolAsync(toolName: toolName, arguments: new Dictionary<string, object?>
{
@ -105,6 +163,20 @@ switch (scenario)
success &= !(result.IsError == true);
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:
// No extra processing for other scenarios
break;

View file

@ -1,7 +1,11 @@
using ConformanceServer.Prompts;
using ConformanceServer.Resources;
using ConformanceServer.Tools;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Collections.Concurrent;
using System.Text.Json;
@ -26,8 +30,28 @@ public class Program
builder.Services
.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.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>()
.WithResources<ConformanceResources>()
.WithSubscribeToResourcesHandler(async (ctx, ct) =>

View file

@ -3,7 +3,6 @@ using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ConformanceServer.Tools;
@ -399,4 +398,48 @@ public class ConformanceTools
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";
}
}

View file

@ -411,7 +411,7 @@ public class McpServerBuilderExtensionsMessageFilterTests(ITestOutputHelper test
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);
}

View file

@ -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;
}
}
}
}

View file

@ -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;
}
}
}

View file

@ -70,7 +70,9 @@ public class TaskCancellationIntegrationTests : ClientServerTestBase
{
Name = "long-running-tool",
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);
@ -333,13 +335,16 @@ public class TaskCancellationConcurrencyTests : ClientServerTestBase
RegisterMarker("short-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(
new CallToolRequestParams
{
Name = "trackable-tool",
Arguments = CreateMarkerArgs("short-ttl"),
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMilliseconds(200) }
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromSeconds(5) }
},
cancellationToken: TestContext.Current.CancellationToken);

View file

@ -45,6 +45,14 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
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.Contains(id, sb.ToString());
}