diff --git a/.github/workflows/ci-build-test.yml b/.github/workflows/ci-build-test.yml index 5731f836..e4e4be37 100644 --- a/.github/workflows/ci-build-test.yml +++ b/.github/workflows/ci-build-test.yml @@ -67,6 +67,9 @@ jobs: - name: 📦 Install dependencies for tests run: npm install @modelcontextprotocol/server-memory + - name: 📦 Install dependencies for tests + run: npm install @modelcontextprotocol/conformance + - name: 🏗️ Build run: make build CONFIGURATION=${{ matrix.configuration }} diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 1f6dce1e..72ecc778 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -70,6 +70,7 @@ + diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 5aff9fb8..1915c761 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -16,7 +16,7 @@ public static partial class McpJsonUtilities /// /// /// - /// For Native AOT or applications disabling , this instance + /// For Native AOT or applications disabling , this instance /// includes source generated contracts for all common exchange types contained in the ModelContextProtocol library. /// /// @@ -88,7 +88,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, NumberHandling = JsonNumberHandling.AllowReadingFromString)] - + // JSON-RPC [JsonSerializable(typeof(JsonRpcMessage))] [JsonSerializable(typeof(JsonRpcMessage[]))] @@ -146,7 +146,10 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(AudioContentBlock))] [JsonSerializable(typeof(EmbeddedResourceBlock))] [JsonSerializable(typeof(ResourceLinkBlock))] + [JsonSerializable(typeof(ContentBlock[]))] [JsonSerializable(typeof(IEnumerable))] + [JsonSerializable(typeof(PromptMessage))] + [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(PromptReference))] [JsonSerializable(typeof(ResourceTemplateReference))] [JsonSerializable(typeof(BlobResourceContents))] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index 76f2a181..128b5158 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -57,6 +57,7 @@ + diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs new file mode 100644 index 00000000..75888c2d --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -0,0 +1,209 @@ +using System.Diagnostics; +using System.Text; +using ModelContextProtocol.Tests.Utils; + +namespace ModelContextProtocol.ConformanceTests; + +/// +/// 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. +/// +public class ServerConformanceTests : IAsyncLifetime +{ + // Use different ports for each target framework to allow parallel execution + // net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003 + private static int GetPortForTargetFramework() + { + var testBinaryDir = AppContext.BaseDirectory; + var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + + return targetFramework switch + { + "net10.0" => 3001, + "net9.0" => 3002, + "net8.0" => 3003, + _ => 3001 // Default fallback + }; + } + + 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 async ValueTask InitializeAsync() + { + // Start the ConformanceServer + StartConformanceServer(); + + // Wait for server to be ready (retry for up to 30 seconds) + var timeout = TimeSpan.FromSeconds(30); + var stopwatch = Stopwatch.StartNew(); + using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(2) }; + + while (stopwatch.Elapsed < timeout) + { + try + { + // Try to connect to the health endpoint + await httpClient.GetAsync($"{_serverUrl}/health"); + // Any response (even an error) means the server is up + return; + } + catch (HttpRequestException) + { + // Connection refused means server not ready yet + } + catch (TaskCanceledException) + { + // Timeout means server might be processing, give it more time + } + + await Task.Delay(500); + } + + throw new InvalidOperationException("ConformanceServer failed to start within the timeout period"); + } + + public async ValueTask DisposeAsync() + { + // Stop the server + if (_serverCts != null) + { + _serverCts.Cancel(); + if (_serverTask != null) + { + try + { + await _serverTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch + { + // Ignore exceptions during shutdown + } + } + _serverCts.Dispose(); + } + } + + [Fact] + public async Task RunConformanceTests() + { + // Check if Node.js is installed + Assert.SkipWhen(!IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + + // Run the conformance test suite + var result = await RunNpxConformanceTests(); + + // Report the results + Assert.True(result.Success, + $"Conformance tests failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + } + + private void StartConformanceServer() + { + // The ConformanceServer binary is in a parallel directory to the test binary + // Test binary is in: artifacts/bin/ModelContextProtocol.ConformanceTests/Debug/{tfm}/ + // ConformanceServer binary is in: artifacts/bin/ModelContextProtocol.ConformanceServer/Debug/{tfm}/ + var testBinaryDir = AppContext.BaseDirectory; // e.g., .../net10.0/ + var configuration = Path.GetFileName(Path.GetDirectoryName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar))!); + var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + var conformanceServerDir = Path.GetFullPath( + Path.Combine(testBinaryDir, "..", "..", "..", "ModelContextProtocol.ConformanceServer", configuration, targetFramework)); + + if (!Directory.Exists(conformanceServerDir)) + { + throw new DirectoryNotFoundException( + $"ConformanceServer directory not found at: {conformanceServerDir}"); + } + + // Start the server in a background task + _serverCts = new CancellationTokenSource(); + _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(["--urls", _serverUrl], new XunitLoggerProvider(_output), cancellationToken: _serverCts.Token)); + } + + private async Task<(bool Success, string Output, string Error)> RunNpxConformanceTests() + { + var startInfo = new ProcessStartInfo + { + FileName = "npx", + Arguments = $"-y @modelcontextprotocol/conformance server --url {_serverUrl}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + var outputBuilder = new StringBuilder(); + var errorBuilder = new StringBuilder(); + + var process = new Process { StartInfo = startInfo }; + + process.OutputDataReceived += (sender, e) => + { + if (e.Data != null) + { + _output.WriteLine(e.Data); + outputBuilder.AppendLine(e.Data); + } + }; + + process.ErrorDataReceived += (sender, e) => + { + if (e.Data != null) + { + _output.WriteLine(e.Data); + errorBuilder.AppendLine(e.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(); + + return ( + Success: process.ExitCode == 0, + Output: outputBuilder.ToString(), + Error: errorBuilder.ToString() + ); + } + + private static bool IsNodeInstalled() + { + try + { + var startInfo = new ProcessStartInfo + { + FileName = "npx", // Check specifically for npx because windows seems unable to find it + Arguments = "--version", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(startInfo); + if (process == null) + { + return false; + } + + process.WaitForExit(5000); + return process.ExitCode == 0; + } + catch + { + return false; + } + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj new file mode 100644 index 00000000..73f4f89b --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -0,0 +1,20 @@ + + + + net10.0;net9.0;net8.0 + enable + enable + Exe + ConformanceServer + + + + + false + + + + + + + diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.http b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.http new file mode 100644 index 00000000..27173de1 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.http @@ -0,0 +1,75 @@ +@HostAddress = http://localhost:3001 + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 1, + "method": "ping" +} + +### + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 2, + "method": "initialize", + "params": { + "clientInfo": { + "name": "RestClient", + "version": "0.1.0" + }, + "capabilities": {}, + "protocolVersion": "2025-06-18" + } +} + +### + +@SessionId = XxIXkrK210aKVnZxD8Iu_g + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json +MCP-Protocol-Version: 2025-06-18 +Mcp-Session-Id: {{SessionId}} + +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/list" +} + +### + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json +MCP-Protocol-Version: 2025-06-18 +Mcp-Session-Id: {{SessionId}} + +{ + "jsonrpc": "2.0", + "id": 4, + "method": "resources/list" +} + +### + +POST {{HostAddress}}/ +Accept: application/json, text/event-stream +Content-Type: application/json +MCP-Protocol-Version: 2025-06-18 +Mcp-Session-Id: {{SessionId}} + +{ + "jsonrpc": "2.0", + "id": 5, + "method": "prompts/list" +} diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs new file mode 100644 index 00000000..41993751 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -0,0 +1,118 @@ +using ConformanceServer.Prompts; +using ConformanceServer.Resources; +using ConformanceServer.Tools; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Protocol; +using System.Collections.Concurrent; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.ConformanceServer; + +public class Program +{ + public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvider = null, CancellationToken cancellationToken = default) + { + var builder = WebApplication.CreateBuilder(args); + + if (loggerProvider != null) + { + builder.Logging.ClearProviders(); + builder.Logging.AddProvider(loggerProvider); + } + + // Dictionary of session IDs to a set of resource URIs they are subscribed to + // The value is a ConcurrentDictionary used as a thread-safe HashSet + // because .NET does not have a built-in concurrent HashSet + ConcurrentDictionary> subscriptions = new(); + + builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithTools() + .WithPrompts() + .WithResources() + .WithSubscribeToResourcesHandler(async (ctx, ct) => + { + if (ctx.Server.SessionId == null) + { + throw new McpException("Cannot add subscription for server with null SessionId"); + } + if (ctx.Params?.Uri is { } uri) + { + subscriptions[ctx.Server.SessionId].TryAdd(uri, 0); + + await ctx.Server.SampleAsync([ + new ChatMessage(ChatRole.System, "You are a helpful test server"), + new ChatMessage(ChatRole.User, $"Resource {uri}, context: A new subscription was started"), + ], + options: new ChatOptions + { + MaxOutputTokens = 100, + Temperature = 0.7f, + }, + cancellationToken: ct); + } + + return new EmptyResult(); + }) + .WithUnsubscribeFromResourcesHandler(async (ctx, ct) => + { + if (ctx.Server.SessionId == null) + { + throw new McpException("Cannot remove subscription for server with null SessionId"); + } + if (ctx.Params?.Uri is { } uri) + { + subscriptions[ctx.Server.SessionId].TryRemove(uri, out _); + } + return new EmptyResult(); + }) + .WithCompleteHandler(async (ctx, ct) => + { + // Basic completion support - returns empty array for conformance + // Real implementations would provide contextual suggestions + return new CompleteResult + { + Completion = new Completion + { + Values = [], + HasMore = false, + Total = 0 + } + }; + }) + .WithSetLoggingLevelHandler(async (ctx, ct) => + { + if (ctx.Params?.Level is null) + { + throw new McpProtocolException("Missing required argument 'level'", McpErrorCode.InvalidParams); + } + + // The SDK updates the LoggingLevel field of the McpServer + // Send a log notification to confirm the level was set + await ctx.Server.SendNotificationAsync("notifications/message", new LoggingMessageNotificationParams + { + Level = LoggingLevel.Info, + Logger = "conformance-test-server", + Data = JsonElement.Parse($"\"Log level set to: {ctx.Params.Level}\""), + }, cancellationToken: ct); + + return new EmptyResult(); + }); + + var app = builder.Build(); + + app.MapMcp(); + + app.MapGet("/health", () => TypedResults.Ok("Healthy")); + + await app.RunAsync(cancellationToken); + } + + public static async Task Main(string[] args) + { + await MainAsync(args); + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/Prompts/ConformancePrompts.cs b/tests/ModelContextProtocol.ConformanceServer/Prompts/ConformancePrompts.cs new file mode 100644 index 00000000..345e215b --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Prompts/ConformancePrompts.cs @@ -0,0 +1,67 @@ +using ModelContextProtocol.Server; +using ModelContextProtocol.Protocol; +using Microsoft.Extensions.AI; +using System.ComponentModel; + +namespace ConformanceServer.Prompts; + +public class ConformancePrompts +{ + // Sample base64 encoded 1x1 red PNG pixel for testing + private const string TestImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + + [McpServerPrompt(Name = "test_simple_prompt"), Description("Simple prompt without arguments")] + public static string SimplePrompt() => "This is a simple prompt without arguments"; + + [McpServerPrompt(Name = "test_prompt_with_arguments"), Description("Parameterized prompt")] + public static string ParameterizedPrompt( + [Description("First test argument")] string arg1, + [Description("Second test argument")] string arg2) + { + return $"Prompt with arguments: arg1={arg1}, arg2={arg2}"; + } + + [McpServerPrompt(Name = "test_prompt_with_embedded_resource"), Description("Prompt with embedded resource")] + public static IEnumerable PromptWithEmbeddedResource( + [Description("URI of the resource to embed")] string resourceUri) + { + return [ + new PromptMessage + { + Role = Role.User, + Content = new EmbeddedResourceBlock + { + Resource = new TextResourceContents + { + Uri = resourceUri, + Text = "Embedded resource content for testing.", + MimeType = "text/plain" + } + } + }, + new PromptMessage { Role = Role.User, Content = new TextContentBlock { Text = "Please process the embedded resource above." } }, + ]; + } + + [McpServerPrompt(Name = "test_prompt_with_image"), Description("Prompt with image")] + public static IEnumerable PromptWithImage() + { + return [ + new PromptMessage + { + Role = Role.User, + Content = new ImageContentBlock + { + MimeType = "image/png", + Data = TestImageBase64 + } + }, + new PromptMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = "Please analyze the image above." } + }, + ]; + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/Properties/launchSettings.json b/tests/ModelContextProtocol.ConformanceServer/Properties/launchSettings.json new file mode 100644 index 00000000..cf992417 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7292;http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/Resources/ConformanceResources.cs b/tests/ModelContextProtocol.ConformanceServer/Resources/ConformanceResources.cs new file mode 100644 index 00000000..1e36cb64 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Resources/ConformanceResources.cs @@ -0,0 +1,72 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ConformanceServer.Resources; + +[McpServerResourceType] +public class ConformanceResources +{ + // Sample base64 encoded 1x1 red PNG pixel for testing + private const string TestImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + + /// + /// Static text resource for testing + /// + [McpServerResource(UriTemplate = "test://static-text", Name = "Static Text Resource", MimeType = "text/plain")] + [Description("A static text resource for testing")] + public static string StaticText() + { + return "This is the content of the static text resource."; + } + + /// + /// Static binary resource (image) for testing + /// + [McpServerResource(UriTemplate = "test://static-binary", Name = "Static Binary Resource", MimeType = "image/png")] + [Description("A static binary resource (image) for testing")] + public static BlobResourceContents StaticBinary() + { + return new BlobResourceContents + { + Uri = "test://static-binary", + MimeType = "image/png", + Blob = TestImageBase64 + }; + } + + /// + /// Resource template with parameter substitution + /// + [McpServerResource(UriTemplate = "test://template/{id}/data", Name = "Resource Template", MimeType = "application/json")] + [Description("A resource template with parameter substitution")] + public static TextResourceContents TemplateResource(string id) + { + var data = new ResourceData(id, true, $"Data for ID: {id}"); + + return new TextResourceContents + { + Uri = $"test://template/{id}/data", + MimeType = "application/json", + Text = JsonSerializer.Serialize(data, JsonContext.Default.ResourceData) + }; + } + + /// + /// Subscribable resource that can send updates + /// + [McpServerResource(UriTemplate = "test://watched-resource", Name = "Watched Resource", MimeType = "text/plain")] + [Description("A resource that auto-updates every 3 seconds")] + public static string WatchedResource() + { + return "Watched resource content"; + } +} + +record ResourceData(string Id, bool TemplateTest, string Data); + +[JsonSerializable(typeof(ResourceData))] +internal partial class JsonContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs new file mode 100644 index 00000000..f624e5ca --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs @@ -0,0 +1,370 @@ +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ConformanceServer.Tools; + +[McpServerToolType] +public class ConformanceTools +{ + // Sample base64 encoded 1x1 red PNG pixel for testing + private const string TestImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + + // Sample base64 encoded minimal WAV file for testing + private const string TestAudioBase64 = + "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="; + + /// + /// Simple text tool - returns simple text content for testing + /// + [McpServerTool(Name = "test_simple_text")] + [Description("Tests simple text content response")] + public static string SimpleText() + { + return "This is a simple text response for testing."; + } + + /// + /// Image content tool - returns base64-encoded image content + /// + [McpServerTool(Name = "test_image_content")] + [Description("Tests image content response")] + public static ImageContentBlock ImageContent() + { + return new ImageContentBlock + { + Data = TestImageBase64, + MimeType = "image/png" + }; + } + + /// + /// Audio content tool - returns base64-encoded audio content + /// + [McpServerTool(Name = "test_audio_content")] + [Description("Tests audio content response")] + public static AudioContentBlock AudioContent() + { + return new AudioContentBlock + { + Data = TestAudioBase64, + MimeType = "audio/wav" + }; + } + + /// + /// Embedded resource tool - returns embedded resource content + /// + [McpServerTool(Name = "test_embedded_resource")] + [Description("Tests embedded resource content response")] + public static EmbeddedResourceBlock EmbeddedResource() + { + return new EmbeddedResourceBlock + { + Resource = new TextResourceContents + { + Uri = "test://embedded-resource", + MimeType = "text/plain", + Text = "This is an embedded resource content." + } + }; + } + + /// + /// Multiple content types tool - returns mixed content types (text, image, resource) + /// + [McpServerTool(Name = "test_multiple_content_types")] + [Description("Tests response with multiple content types (text, image, resource)")] + public static ContentBlock[] MultipleContentTypes() + { + return + [ + new TextContentBlock { Text = "Multiple content types test:" }, + new ImageContentBlock { Data = TestImageBase64, MimeType = "image/png" }, + new EmbeddedResourceBlock + { + Resource = new TextResourceContents + { + Uri = "test://mixed-content-resource", + MimeType = "application/json", + Text = "{ \"test\" = \"data\", \"value\" = 123 }" + } + } + ]; + } + + /// + /// Tool with logging - emits log messages during execution + /// + [McpServerTool(Name = "test_tool_with_logging")] + [Description("Tests tool that emits log messages during execution")] + public static async Task ToolWithLogging( + RequestContext context, + CancellationToken cancellationToken) + { + var server = context.Server; + + // Use ILogger for logging (will be forwarded to client if supported) + ILoggerProvider loggerProvider = server.AsClientLoggerProvider(); + ILogger logger = loggerProvider.CreateLogger("ConformanceTools"); + + logger.LogInformation("Tool execution started"); + await Task.Delay(50, cancellationToken); + + logger.LogInformation("Tool processing data"); + await Task.Delay(50, cancellationToken); + + logger.LogInformation("Tool execution completed"); + + return "Tool with logging executed successfully"; + } + + /// + /// Tool with progress - reports progress notifications + /// + [McpServerTool(Name = "test_tool_with_progress")] + [Description("Tests tool that reports progress notifications")] + public static async Task ToolWithProgress( + McpServer server, + RequestContext context, + CancellationToken cancellationToken) + { + var progressToken = context.Params?.ProgressToken; + + if (progressToken is not null) + { + await server.NotifyProgressAsync(progressToken.Value, new ProgressNotificationValue + { + Progress = 0, + Total = 100, + }, cancellationToken); + + await Task.Delay(50, cancellationToken); + + await server.NotifyProgressAsync(progressToken.Value, new ProgressNotificationValue + { + Progress = 50, + Total = 100, + }, cancellationToken); + + await Task.Delay(50, cancellationToken); + + await server.NotifyProgressAsync(progressToken.Value, new ProgressNotificationValue + { + Progress = 100, + Total = 100, + }, cancellationToken); + } + + return progressToken?.ToString() ?? "No progress token provided"; + } + + /// + /// Error handling tool - intentionally throws an error for testing + /// + [McpServerTool(Name = "test_error_handling")] + [Description("Tests error response handling")] + public static string ErrorHandling() + { + throw new Exception("This tool intentionally returns an error for testing"); + } + + /// + /// Sampling tool - requests LLM completion from client + /// + [McpServerTool(Name = "test_sampling")] + [Description("Tests server-initiated sampling (LLM completion request)")] + public static async Task Sampling( + McpServer server, + [Description("The prompt to send to the LLM")] string prompt, + CancellationToken cancellationToken) + { + try + { + var samplingParams = new CreateMessageRequestParams + { + Messages = [new SamplingMessage + { + Role = Role.User, + Content = [new TextContentBlock { Text = prompt }], + }], + MaxTokens = 100, + Temperature = 0.7f + }; + + var result = await server.SampleAsync(samplingParams, cancellationToken); + return $"Sampling result: {(result.Content.FirstOrDefault() as TextContentBlock)?.Text ?? "No text content"}"; + } + catch (Exception ex) + { + return $"Sampling not supported or error: {ex.Message}"; + } + } + + /// + /// Elicitation tool - requests user input from client + /// + [McpServerTool(Name = "test_elicitation")] + [Description("Tests elicitation (user input request from client)")] + public static async Task Elicitation( + McpServer server, + [Description("Message to show to the user")] string message, + CancellationToken cancellationToken) + { + try + { + var schema = new ElicitRequestParams.RequestSchema + { + Properties = + { + ["response"] = new ElicitRequestParams.StringSchema() + { + Description = "User's response to the message" + } + } + }; + + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = message, + RequestedSchema = schema + }, cancellationToken); + + if (result.Action == "accept" && result.Content != null) + { + return $"User responded: {result.Content["response"].GetString()}"; + } + else + { + return $"Elicitation {result.Action}"; + } + } + catch (Exception ex) + { + return $"Elicitation not supported or error: {ex.Message}"; + } + } + + /// + /// SEP-1034: Elicitation with default values for all primitive types + /// + [McpServerTool(Name = "test_elicitation_sep1034_defaults")] + [Description("Tests elicitation with default values per SEP-1034")] + public static async Task ElicitationSep1034Defaults( + McpServer server, + CancellationToken cancellationToken) + { + try + { + var schema = new ElicitRequestParams.RequestSchema + { + Properties = + { + ["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 + }, + ["status"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema() + { + Description = "Status", + Enum = ["active", "inactive", "pending"], + Default = "active" + }, + ["verified"] = new ElicitRequestParams.BooleanSchema() + { + Description = "Verified", + Default = true + } + } + }; + + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = "Test elicitation with default values for primitive types", + RequestedSchema = schema + }, cancellationToken); + + if (result.Action == "accept" && result.Content != null) + { + return $"Accepted with values: string={result.Content["stringField"].GetString()}, " + + $"number={result.Content["numberField"].GetInt32()}, " + + $"boolean={result.Content["booleanField"].GetBoolean()}"; + } + else + { + return $"Elicitation {result.Action}"; + } + } + catch (Exception ex) + { + return $"Elicitation not supported or error: {ex.Message}"; + } + } + + /// + /// SEP-1330: Elicitation with enum schema improvements + /// + [McpServerTool(Name = "test_elicitation_sep1330_enums")] + [Description("Tests elicitation with enum schema improvements per SEP-1330")] + public static async Task ElicitationSep1330Enums( + McpServer server, + CancellationToken cancellationToken) + { + try + { + var schema = new ElicitRequestParams.RequestSchema + { + Properties = + { + ["color"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema() + { + Description = "Choose a color", + Enum = ["red", "green", "blue"] + }, + ["size"] = new ElicitRequestParams.UntitledSingleSelectEnumSchema() + { + Description = "Choose a size", + Enum = ["small", "medium", "large"], + Default = "medium" + } + } + }; + + var result = await server.ElicitAsync(new ElicitRequestParams + { + Message = "Test elicitation with enum schema", + RequestedSchema = schema + }, cancellationToken); + + if (result.Action == "accept" && result.Content != null) + { + return $"Accepted with values: color={result.Content["color"].GetString()}, " + + $"size={result.Content["size"].GetString()}"; + } + else + { + return $"Elicitation {result.Action}"; + } + } + catch (Exception ex) + { + return $"Elicitation not supported or error: {ex.Message}"; + } + } +} \ No newline at end of file diff --git a/tests/ModelContextProtocol.ConformanceServer/appsettings.Development.json b/tests/ModelContextProtocol.ConformanceServer/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/appsettings.json b/tests/ModelContextProtocol.ConformanceServer/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}