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