Add Conformance tests for the server (#983)

This commit is contained in:
Mike Kistler 2025-11-25 17:20:18 -08:00 • committed by GitHub
commit f45996d4b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 981 additions and 2 deletions

View file

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

View file

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

View 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);
}
}

View file

@ -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." }
},
];
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}