Add Conformance tests for the server (#983)
This commit is contained in:
parent
5a65af8ed7
commit
f45996d4b5
14 changed files with 981 additions and 2 deletions
3
.github/workflows/ci-build-test.yml
vendored
3
.github/workflows/ci-build-test.yml
vendored
|
|
@ -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 }}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@
|
|||
<Folder Name="/tests/">
|
||||
<Project Path="tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj" />
|
||||
<Project Path="tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj" />
|
||||
<Project Path="tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj" />
|
||||
<Project Path="tests/ModelContextProtocol.TestOAuthServer/ModelContextProtocol.TestOAuthServer.csproj" />
|
||||
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />
|
||||
<Project Path="tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj" />
|
||||
|
|
|
|||
|
|
@ -146,7 +146,10 @@ public static partial class McpJsonUtilities
|
|||
[JsonSerializable(typeof(AudioContentBlock))]
|
||||
[JsonSerializable(typeof(EmbeddedResourceBlock))]
|
||||
[JsonSerializable(typeof(ResourceLinkBlock))]
|
||||
[JsonSerializable(typeof(ContentBlock[]))]
|
||||
[JsonSerializable(typeof(IEnumerable<ContentBlock>))]
|
||||
[JsonSerializable(typeof(PromptMessage))]
|
||||
[JsonSerializable(typeof(IEnumerable<PromptMessage>))]
|
||||
[JsonSerializable(typeof(PromptReference))]
|
||||
[JsonSerializable(typeof(ResourceTemplateReference))]
|
||||
[JsonSerializable(typeof(BlobResourceContents))]
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\ModelContextProtocol.TestSseServer\ModelContextProtocol.TestSseServer.csproj" />
|
||||
<ProjectReference Include="..\ModelContextProtocol.TestOAuthServer\ModelContextProtocol.TestOAuthServer.csproj" />
|
||||
<ProjectReference Include="..\ModelContextProtocol.ConformanceServer\ModelContextProtocol.ConformanceServer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>ConformanceServer</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'net9.0'">
|
||||
<!-- For better test coverage, only disable reflection in one of the targets -->
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -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"
|
||||
}
|
||||
118
tests/ModelContextProtocol.ConformanceServer/Program.cs
Normal file
118
tests/ModelContextProtocol.ConformanceServer/Program.cs
Normal file
|
|
@ -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<string, ConcurrentDictionary<string, byte>> subscriptions = new();
|
||||
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithTools<ConformanceTools>()
|
||||
.WithPrompts<ConformancePrompts>()
|
||||
.WithResources<ConformanceResources>()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PromptMessage> 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<PromptMessage> 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." }
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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==";
|
||||
|
||||
/// <summary>
|
||||
/// Static text resource for testing
|
||||
/// </summary>
|
||||
[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.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static binary resource (image) for testing
|
||||
/// </summary>
|
||||
[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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resource template with parameter substitution
|
||||
/// </summary>
|
||||
[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)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribable resource that can send updates
|
||||
/// </summary>
|
||||
[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;
|
||||
|
|
@ -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=";
|
||||
|
||||
/// <summary>
|
||||
/// Simple text tool - returns simple text content for testing
|
||||
/// </summary>
|
||||
[McpServerTool(Name = "test_simple_text")]
|
||||
[Description("Tests simple text content response")]
|
||||
public static string SimpleText()
|
||||
{
|
||||
return "This is a simple text response for testing.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Image content tool - returns base64-encoded image content
|
||||
/// </summary>
|
||||
[McpServerTool(Name = "test_image_content")]
|
||||
[Description("Tests image content response")]
|
||||
public static ImageContentBlock ImageContent()
|
||||
{
|
||||
return new ImageContentBlock
|
||||
{
|
||||
Data = TestImageBase64,
|
||||
MimeType = "image/png"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Audio content tool - returns base64-encoded audio content
|
||||
/// </summary>
|
||||
[McpServerTool(Name = "test_audio_content")]
|
||||
[Description("Tests audio content response")]
|
||||
public static AudioContentBlock AudioContent()
|
||||
{
|
||||
return new AudioContentBlock
|
||||
{
|
||||
Data = TestAudioBase64,
|
||||
MimeType = "audio/wav"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embedded resource tool - returns embedded resource content
|
||||
/// </summary>
|
||||
[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."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Multiple content types tool - returns mixed content types (text, image, resource)
|
||||
/// </summary>
|
||||
[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 }"
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tool with logging - emits log messages during execution
|
||||
/// </summary>
|
||||
[McpServerTool(Name = "test_tool_with_logging")]
|
||||
[Description("Tests tool that emits log messages during execution")]
|
||||
public static async Task<string> ToolWithLogging(
|
||||
RequestContext<CallToolRequestParams> 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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tool with progress - reports progress notifications
|
||||
/// </summary>
|
||||
[McpServerTool(Name = "test_tool_with_progress")]
|
||||
[Description("Tests tool that reports progress notifications")]
|
||||
public static async Task<string> ToolWithProgress(
|
||||
McpServer server,
|
||||
RequestContext<CallToolRequestParams> 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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error handling tool - intentionally throws an error for testing
|
||||
/// </summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sampling tool - requests LLM completion from client
|
||||
/// </summary>
|
||||
[McpServerTool(Name = "test_sampling")]
|
||||
[Description("Tests server-initiated sampling (LLM completion request)")]
|
||||
public static async Task<string> 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}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Elicitation tool - requests user input from client
|
||||
/// </summary>
|
||||
[McpServerTool(Name = "test_elicitation")]
|
||||
[Description("Tests elicitation (user input request from client)")]
|
||||
public static async Task<string> 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}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEP-1034: Elicitation with default values for all primitive types
|
||||
/// </summary>
|
||||
[McpServerTool(Name = "test_elicitation_sep1034_defaults")]
|
||||
[Description("Tests elicitation with default values per SEP-1034")]
|
||||
public static async Task<string> 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}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEP-1330: Elicitation with enum schema improvements
|
||||
/// </summary>
|
||||
[McpServerTool(Name = "test_elicitation_sep1330_enums")]
|
||||
[Description("Tests elicitation with enum schema improvements per SEP-1330")]
|
||||
public static async Task<string> 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
Loading…
Reference in a new issue