diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 64e834b35..67d02a04e 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -73,6 +73,7 @@ + diff --git a/src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs b/src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs new file mode 100644 index 000000000..ae13b721b --- /dev/null +++ b/src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs @@ -0,0 +1,51 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; + +namespace ModelContextProtocol.Analyzers; + +/// +/// Suppresses MCPEXP001 diagnostics in source-generated code. +/// +/// +/// +/// The MCP SDK uses object? backing fields with [JsonConverter(typeof(ExperimentalJsonConverter<T>))] +/// to handle serialization of experimental types. When consumers define their own JsonSerializerContext, +/// the System.Text.Json source generator emits code referencing these converters with experimental type arguments, +/// which triggers MCPEXP001 diagnostics in the generated code. +/// +/// +/// This suppressor suppresses MCPEXP001 only in source-generated files (identified by .g.cs file extension), +/// so that hand-written user code that directly references experimental types still produces the diagnostic. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class MCPEXP001Suppressor : DiagnosticSuppressor +{ + private static readonly SuppressionDescriptor SuppressInGeneratedCode = new( + id: "MCP_MCPEXP001_GENERATED", + suppressedDiagnosticId: "MCPEXP001", + justification: "MCPEXP001 is suppressed in source-generated code because the experimental type reference originates from the MCP SDK's backing field infrastructure, not from user code."); + + /// + public override ImmutableArray SupportedSuppressions => + ImmutableArray.Create(SuppressInGeneratedCode); + + /// + public override void ReportSuppressions(SuppressionAnalysisContext context) + { + foreach (Diagnostic diagnostic in context.ReportedDiagnostics) + { + if (diagnostic.Id == "MCPEXP001" && IsInGeneratedCode(diagnostic)) + { + context.ReportSuppression(Suppression.Create(SuppressInGeneratedCode, diagnostic)); + } + } + } + + private static bool IsInGeneratedCode(Diagnostic diagnostic) + { + string? filePath = diagnostic.Location.SourceTree?.FilePath; + return filePath is not null && filePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/ModelContextProtocol.Core/ExperimentalJsonConverter.cs b/src/ModelContextProtocol.Core/ExperimentalJsonConverter.cs new file mode 100644 index 000000000..b3ced6c8a --- /dev/null +++ b/src/ModelContextProtocol.Core/ExperimentalJsonConverter.cs @@ -0,0 +1,59 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol; + +/// +/// A JSON converter that handles serialization of experimental MCP types through object? backing fields. +/// +/// The experimental type to serialize/deserialize. +/// +/// +/// This converter is used on object? backing fields that shadow public experimental properties +/// marked with . By declaring the backing field +/// as object?, the System.Text.Json source generator does not walk the experimental type graph. +/// +/// +/// Serialization delegates to , which already contains source-generated +/// contracts for all experimental types. +/// +/// +/// This type is not intended to be used directly. It supports the MCP infrastructure and is subject to change. +/// +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public class ExperimentalJsonConverter : JsonConverter where T : class +{ + private static JsonTypeInfo TypeInfo => (JsonTypeInfo)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T)); + + /// + public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + return JsonSerializer.Deserialize(ref reader, TypeInfo); + } + + /// + public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + if (value is not T typed) + { + throw new JsonException($"Expected value of type '{typeof(T).Name}' but got '{value.GetType().Name}'."); + } + + JsonSerializer.Serialize(writer, typed, TypeInfo); + } +} diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs index fa6939d7a..75266c73b 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -33,6 +35,18 @@ public sealed class CallToolRequestParams : RequestParams /// When present, indicates that the requestor wants this operation executed as a task. /// The receiver must support task augmentation for this specific request type. /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + [JsonIgnore] + public McpTaskMetadata? Task + { + get => _task as McpTaskMetadata; + set => _task = value; + } + + /// Backing field for . This field is not intended to be used directly. + [JsonInclude] [JsonPropertyName("task")] - public McpTaskMetadata? Task { get; set; } + [JsonConverter(typeof(ExperimentalJsonConverter))] + [EditorBrowsable(EditorBrowsableState.Never)] + public object? _task; } diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs index 509436010..8f7058a86 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -65,6 +67,18 @@ public sealed class CallToolResult : Result /// (, , ) may not be populated. /// The actual tool result can be retrieved later via tasks/result. /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + [JsonIgnore] + public McpTask? Task + { + get => _task as McpTask; + set => _task = value; + } + + /// Backing field for . This field is not intended to be used directly. + [JsonInclude] [JsonPropertyName("task")] - public McpTask? Task { get; set; } + [JsonConverter(typeof(ExperimentalJsonConverter))] + [EditorBrowsable(EditorBrowsableState.Never)] + public object? _task; } diff --git a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs index cb85ef5e3..914f4d104 100644 --- a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using ModelContextProtocol.Client; using ModelContextProtocol.Server; @@ -80,6 +81,18 @@ public sealed class ClientCapabilities /// See for details on configuring which operations support tasks. /// /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + [JsonIgnore] + public McpTasksCapability? Tasks + { + get => _tasks as McpTasksCapability; + set => _tasks = value; + } + + /// Backing field for . This field is not intended to be used directly. + [JsonInclude] [JsonPropertyName("tasks")] - public McpTasksCapability? Tasks { get; set; } + [JsonConverter(typeof(ExperimentalJsonConverter))] + [EditorBrowsable(EditorBrowsableState.Never)] + public object? _tasks; } diff --git a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs index 59be7fab8..1516c6e66 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs @@ -1,3 +1,5 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -128,6 +130,18 @@ public sealed class CreateMessageRequestParams : RequestParams /// When present, indicates that the requestor wants this operation executed as a task. /// The receiver must support task augmentation for this specific request type. /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + [JsonIgnore] + public McpTaskMetadata? Task + { + get => _task as McpTaskMetadata; + set => _task = value; + } + + /// Backing field for . This field is not intended to be used directly. + [JsonInclude] [JsonPropertyName("task")] - public McpTaskMetadata? Task { get; set; } + [JsonConverter(typeof(ExperimentalJsonConverter))] + [EditorBrowsable(EditorBrowsableState.Never)] + public object? _task; } diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index 2e50824e1..bc14ad967 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -99,8 +99,20 @@ public string Mode /// When present, indicates that the requestor wants this operation executed as a task. /// The receiver must support task augmentation for this specific request type. /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + [JsonIgnore] + public McpTaskMetadata? Task + { + get => _task as McpTaskMetadata; + set => _task = value; + } + + /// Backing field for . This field is not intended to be used directly. + [JsonInclude] [JsonPropertyName("task")] - public McpTaskMetadata? Task { get; set; } + [JsonConverter(typeof(ExperimentalJsonConverter))] + [EditorBrowsable(EditorBrowsableState.Never)] + public object? _task; /// Represents a request schema used in a form mode elicitation request. public sealed class RequestSchema diff --git a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs index 499819662..e6cd87430 100644 --- a/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using ModelContextProtocol.Server; @@ -79,6 +80,18 @@ public sealed class ServerCapabilities /// See for details on configuring which operations support tasks. /// /// + [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + [JsonIgnore] + public McpTasksCapability? Tasks + { + get => _tasks as McpTasksCapability; + set => _tasks = value; + } + + /// Backing field for . This field is not intended to be used directly. + [JsonInclude] [JsonPropertyName("tasks")] - public McpTasksCapability? Tasks { get; set; } + [JsonConverter(typeof(ExperimentalJsonConverter))] + [EditorBrowsable(EditorBrowsableState.Never)] + public object? _tasks; } diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs index 8e06d7104..9876a1834 100644 --- a/src/ModelContextProtocol.Core/Protocol/Tool.cs +++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Server; +using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Text.Json; @@ -120,8 +121,19 @@ public JsonElement? OutputSchema /// regarding task augmentation support. See for details. /// [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + [JsonIgnore] + public ToolExecution? Execution + { + get => _execution as ToolExecution; + set => _execution = value; + } + + /// Backing field for . This field is not intended to be used directly. + [JsonInclude] [JsonPropertyName("execution")] - public ToolExecution? Execution { get; set; } + [JsonConverter(typeof(ExperimentalJsonConverter))] + [EditorBrowsable(EditorBrowsableState.Never)] + public object? _execution; /// /// Gets or sets an optional list of icons for this tool. diff --git a/tests/ModelContextProtocol.Analyzers.Tests/MCPEXP001SuppressorTests.cs b/tests/ModelContextProtocol.Analyzers.Tests/MCPEXP001SuppressorTests.cs new file mode 100644 index 000000000..2e622f4bb --- /dev/null +++ b/tests/ModelContextProtocol.Analyzers.Tests/MCPEXP001SuppressorTests.cs @@ -0,0 +1,159 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Immutable; +using Xunit; + +namespace ModelContextProtocol.Analyzers.Tests; + +public class MCPEXP001SuppressorTests +{ + [Fact] + public async Task Suppressor_InGeneratedCode_SuppressesMCPEXP001() + { + // Simulate source-generated code (e.g., STJ source gen) that references an experimental type. + // The file path ends with .g.cs to indicate it's generated. + var result = await RunSuppressorAsync( + source: """ + using ExperimentalTypes; + + namespace Generated + { + public static class SerializerHelper + { + public static object Create() => new ExperimentalClass(); + } + } + """, + filePath: "Generated.g.cs", + additionalSource: GetExperimentalTypeDefinition(), + additionalFilePath: "ExperimentalTypes.cs"); + + // MCPEXP001 should exist before the suppressor runs + Assert.Contains(result.BeforeSuppression, d => d.Id == "MCPEXP001"); + + // After suppression, MCPEXP001 should be gone from the results + Assert.DoesNotContain(result.AfterSuppression, d => d.Id == "MCPEXP001"); + } + + [Fact] + public async Task Suppressor_InHandWrittenCode_DoesNotSuppressMCPEXP001() + { + // Hand-written user code referencing an experimental type. + // The file path does NOT end with .g.cs. + var result = await RunSuppressorAsync( + source: """ + using ExperimentalTypes; + + namespace UserCode + { + public static class MyHelper + { + public static object Create() => new ExperimentalClass(); + } + } + """, + filePath: "MyHelper.cs", + additionalSource: GetExperimentalTypeDefinition(), + additionalFilePath: "ExperimentalTypes.cs"); + + // MCPEXP001 should exist before the suppressor runs + Assert.Contains(result.BeforeSuppression, d => d.Id == "MCPEXP001"); + + // It should still be present after the suppressor runs (not suppressed) + Assert.Contains(result.AfterSuppression, d => d.Id == "MCPEXP001"); + } + + [Fact] + public async Task Suppressor_MixedGeneratedAndHandWritten_OnlySuppressesGenerated() + { + var result = await RunSuppressorAsync( + [ + (GetExperimentalTypeDefinition(), "ExperimentalTypes.cs"), + (""" + using ExperimentalTypes; + namespace Generated + { + public static class GeneratedHelper + { + public static object Create() => new ExperimentalClass(); + } + } + """, "Generated.g.cs"), + (""" + using ExperimentalTypes; + namespace UserCode + { + public static class UserHelper + { + public static object Create() => new ExperimentalClass(); + } + } + """, "UserCode.cs"), + ]); + + // Should have MCPEXP001 in both files before suppression + Assert.Equal(2, result.BeforeSuppression.Count(d => d.Id == "MCPEXP001")); + + // After suppression: only the hand-written one should remain + var remaining = result.AfterSuppression.Where(d => d.Id == "MCPEXP001").ToList(); + Assert.Single(remaining); + Assert.Equal("UserCode.cs", remaining[0].Location.SourceTree?.FilePath); + } + + private static string GetExperimentalTypeDefinition() => """ + using System.Diagnostics.CodeAnalysis; + + namespace ExperimentalTypes + { + [Experimental("MCPEXP001")] + public class ExperimentalClass { } + } + """; + + private static Task RunSuppressorAsync( + string source, + string filePath, + string additionalSource, + string additionalFilePath) + { + return RunSuppressorAsync([(additionalSource, additionalFilePath), (source, filePath)]); + } + + private static async Task RunSuppressorAsync(params (string Source, string FilePath)[] sources) + { + var syntaxTrees = sources.Select( + s => CSharpSyntaxTree.ParseText(s.Source, path: s.FilePath)).ToArray(); + + var runtimePath = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + List referenceList = + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(Path.Combine(runtimePath, "System.Runtime.dll")), + MetadataReference.CreateFromFile(Path.Combine(runtimePath, "netstandard.dll")), + ]; + + var compilation = CSharpCompilation.Create( + "TestAssembly", + syntaxTrees, + referenceList, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var beforeSuppression = compilation.GetDiagnostics(); + var analyzers = ImmutableArray.Create(new MCPEXP001Suppressor()); + var compilationWithAnalyzers = compilation.WithAnalyzers(analyzers); + var afterSuppression = await compilationWithAnalyzers.GetAllDiagnosticsAsync(default); + + return new SuppressorResult + { + BeforeSuppression = beforeSuppression, + AfterSuppression = afterSuppression, + }; + } + + private class SuppressorResult + { + public ImmutableArray BeforeSuppression { get; set; } = []; + public ImmutableArray AfterSuppression { get; set; } = []; + } +} diff --git a/tests/ModelContextProtocol.SuppressorRegressionTest/ExperimentalPropertyRegressionContext.cs b/tests/ModelContextProtocol.SuppressorRegressionTest/ExperimentalPropertyRegressionContext.cs new file mode 100644 index 000000000..00b94cb36 --- /dev/null +++ b/tests/ModelContextProtocol.SuppressorRegressionTest/ExperimentalPropertyRegressionContext.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.SuppressorRegressionTest; + +/// +/// This file validates that the MCPEXP001 diagnostic suppressor works correctly. +/// By including MCP protocol types that have experimental backing fields in a +/// , we verify that the source generator does +/// not produce unsuppressed MCPEXP001 diagnostics. If the suppressor is removed +/// or broken, this project will fail to build. +/// +[JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(ServerCapabilities))] +[JsonSerializable(typeof(ClientCapabilities))] +[JsonSerializable(typeof(CallToolResult))] +[JsonSerializable(typeof(CallToolRequestParams))] +[JsonSerializable(typeof(CreateMessageRequestParams))] +[JsonSerializable(typeof(ElicitRequestParams))] +internal partial class ExperimentalPropertyRegressionContext : JsonSerializerContext; diff --git a/tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj b/tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj new file mode 100644 index 000000000..2785c720d --- /dev/null +++ b/tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj @@ -0,0 +1,26 @@ + + + + net10.0;net9.0;net8.0 + enable + enable + false + + + $(NoWarn.Replace('MCPEXP001','')) + + + + + + + + diff --git a/tests/ModelContextProtocol.Tests/ExperimentalBackingFieldTests.cs b/tests/ModelContextProtocol.Tests/ExperimentalBackingFieldTests.cs new file mode 100644 index 000000000..859f7b337 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/ExperimentalBackingFieldTests.cs @@ -0,0 +1,272 @@ +using System.ComponentModel; +using System.Reflection; +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Tests; + +/// +/// Tests that enforce the experimental backing field pattern for MCP protocol types. +/// +/// +/// +/// Experimental properties on serialized protocol types use an "object backing field" pattern to +/// prevent MCPEXP001 diagnostics from surfacing in consumer-generated code. The pattern is: +/// +/// +/// The typed property has [Experimental] and [JsonIgnore]. +/// A public object? backing field handles serialization with [JsonInclude], +/// [JsonPropertyName], [JsonConverter(typeof(ExperimentalJsonConverter<T>))], +/// and [EditorBrowsable(Never)]. +/// +/// +/// Stabilization lifecycle: When an experimental API becomes stable, do NOT +/// remove the backing field immediately. Follow these steps: +/// +/// +/// +/// Stabilize: Remove [Experimental] and [JsonIgnore] from the +/// typed property. Add [JsonPropertyName] to it. On the backing field: remove +/// [JsonInclude], [JsonPropertyName], [JsonConverter]; add +/// [JsonIgnore] and [Obsolete]. Change the registry entry from +/// to . +/// +/// +/// Cleanup (at maintainers' discretion): Remove the backing field and the +/// entry from . This is safe once consumers have +/// had sufficient opportunity to recompile against the stabilized version. +/// +/// +/// +public class ExperimentalBackingFieldTests +{ + /// + /// Registry of experimental properties and their lifecycle state. + /// + /// + /// + /// Every experimental property that participates in JSON serialization must be in this list. + /// When stabilizing, change the entry from to + /// ; do NOT remove it. Only remove once consumers have + /// had sufficient opportunity to recompile. + /// + /// + private static readonly ExperimentalPropertyEntry[] ExperimentalPropertyRegistry = + [ + new ExperimentalProperty(typeof(Tool), nameof(Tool.Execution), "_execution"), + new ExperimentalProperty(typeof(ServerCapabilities), nameof(ServerCapabilities.Tasks), "_tasks"), + new ExperimentalProperty(typeof(ClientCapabilities), nameof(ClientCapabilities.Tasks), "_tasks"), + new ExperimentalProperty(typeof(CallToolResult), nameof(CallToolResult.Task), "_task"), + new ExperimentalProperty(typeof(CallToolRequestParams), nameof(CallToolRequestParams.Task), "_task"), + new ExperimentalProperty(typeof(CreateMessageRequestParams), nameof(CreateMessageRequestParams.Task), "_task"), + new ExperimentalProperty(typeof(ElicitRequestParams), nameof(ElicitRequestParams.Task), "_task"), + ]; + + /// + /// Verifies that each registered property has the correct backing field pattern for its lifecycle state. + /// + [Fact] + public void RegisteredProperties_FollowBackingFieldPattern() + { + foreach (var entry in ExperimentalPropertyRegistry) + { + if (entry is IgnoredExperimentalProperty) + { + continue; + } + + var property = entry.Type.GetProperty(entry.PropertyName, BindingFlags.Public | BindingFlags.Instance); + Assert.True(property is not null, + $"{entry.Type.Name} should have a public property '{entry.PropertyName}'."); + + var fieldName = entry switch + { + ExperimentalProperty e => e.FieldName, + StabilizedProperty s => s.FieldName, + _ => throw new InvalidOperationException($"Unexpected entry type: {entry.GetType().Name}"), + }; + + var field = entry.Type.GetField(fieldName, BindingFlags.Public | BindingFlags.Instance); + Assert.True(field is not null, + $"{entry.Type.Name} should have a public backing field '{fieldName}' for property '{entry.PropertyName}'."); + + // Field must be object? + Assert.True(field.FieldType == typeof(object), + $"{entry.Type.Name}.{fieldName} should be of type 'object?' but is '{field.FieldType.Name}'."); + + // Field must have [EditorBrowsable(Never)] + var editorBrowsable = field.GetCustomAttribute(); + Assert.True(editorBrowsable is not null && editorBrowsable.State == EditorBrowsableState.Never, + $"{entry.Type.Name}.{fieldName} should have [EditorBrowsable(EditorBrowsableState.Never)]."); + + switch (entry) + { + case ExperimentalProperty: + AssertExperimentalPattern(entry.Type, property, field, fieldName); + break; + case StabilizedProperty: + AssertStabilizedPattern(entry.Type, property, field, fieldName); + break; + } + } + } + + /// + /// Verifies that any experimental property participating in JSON serialization is registered + /// in . + /// + /// + /// A property requires registration if it has [Experimental] and either + /// [JsonPropertyName] or [JsonIgnore]. If such a property is missing from + /// the registry, this test fails. + /// + [Fact] + public void ExperimentalJsonProperties_AreInRegistry() + { + var registeredSet = new HashSet<(Type, string)>(ExperimentalPropertyRegistry.Select(e => (e.Type, e.PropertyName))); + var protocolAssembly = typeof(Tool).Assembly; + var protocolTypes = protocolAssembly.GetTypes() + .Where(t => t.Namespace == "ModelContextProtocol.Protocol" && t.IsClass); + + foreach (var type in protocolTypes) + { + foreach (var property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + bool hasExperimental = HasAttribute(property, "ExperimentalAttribute"); + bool hasJsonPropertyName = property.GetCustomAttribute() is not null; + bool hasJsonIgnore = property.GetCustomAttribute() is not null; + + if (hasExperimental && (hasJsonPropertyName || hasJsonIgnore)) + { + Assert.True(registeredSet.Contains((type, property.Name)), + $"{type.Name}.{property.Name} is an experimental JSON property but is not registered in " + + $"{nameof(ExperimentalPropertyRegistry)}. Add it to the registry using " + + $"{nameof(ExperimentalProperty)}, {nameof(StabilizedProperty)}, or " + + $"{nameof(IgnoredExperimentalProperty)}."); + } + } + } + } + + /// + /// Verifies that the typed property getter reads from the backing field and the setter writes to it. + /// + [Fact] + public void RegisteredProperties_GetAndSetBackingField() + { + foreach (var entry in ExperimentalPropertyRegistry) + { + if (entry is IgnoredExperimentalProperty) + { + continue; + } + + var fieldName = entry switch + { + ExperimentalProperty e => e.FieldName, + StabilizedProperty s => s.FieldName, + _ => throw new InvalidOperationException($"Unexpected entry type: {entry.GetType().Name}"), + }; + + var property = entry.Type.GetProperty(entry.PropertyName, BindingFlags.Public | BindingFlags.Instance); + Assert.True(property is not null, + $"{entry.Type.Name} should have a public property '{entry.PropertyName}'."); + + var field = entry.Type.GetField(fieldName, BindingFlags.Public | BindingFlags.Instance); + Assert.True(field is not null, + $"{entry.Type.Name} should have a public backing field '{fieldName}' for property '{entry.PropertyName}'."); + + var instance = Activator.CreateInstance(entry.Type); + var testValue = Activator.CreateInstance(property.PropertyType); + + // Setting the property should write to the backing field + property.SetValue(instance, testValue); + Assert.Same(testValue, field.GetValue(instance)); + + // Setting the backing field should be readable via the property getter + var anotherValue = Activator.CreateInstance(property.PropertyType); + field.SetValue(instance, anotherValue); + Assert.Same(anotherValue, property.GetValue(instance)); + + // Setting the property to null should null the backing field + property.SetValue(instance, null); + Assert.Null(field.GetValue(instance)); + } + } + + private static void AssertExperimentalPattern(Type type, PropertyInfo property, FieldInfo field, string fieldName) + { + // Property must have [Experimental] + Assert.True(HasAttribute(property, "ExperimentalAttribute"), + $"{type.Name}.{property.Name} is registered as {nameof(ExperimentalProperty)} but does not have " + + $"[Experimental]. If this API has been stabilized, change its registry entry to {nameof(StabilizedProperty)}."); + + // Property must have [JsonIgnore] + Assert.True(property.GetCustomAttribute() is not null, + $"{type.Name}.{property.Name} is registered as Experimental and should have [JsonIgnore]."); + + // Field must have [JsonInclude] + Assert.True(field.GetCustomAttribute() is not null, + $"{type.Name}.{fieldName} is registered as Experimental and should have [JsonInclude]."); + + // Field must have [JsonPropertyName] + Assert.True(field.GetCustomAttribute() is not null, + $"{type.Name}.{fieldName} is registered as Experimental and should have [JsonPropertyName]."); + + // Field must have [JsonConverter] + Assert.True(field.GetCustomAttribute() is not null, + $"{type.Name}.{fieldName} is registered as Experimental and should have [JsonConverter]."); + } + + private static void AssertStabilizedPattern(Type type, PropertyInfo property, FieldInfo field, string fieldName) + { + // Property must NOT have [JsonIgnore] (it's now the primary serialization target) + Assert.True(property.GetCustomAttribute() is null, + $"{type.Name}.{property.Name} is registered as Stabilized and should NOT have [JsonIgnore]."); + + // Property must have [JsonPropertyName] + Assert.True(property.GetCustomAttribute() is not null, + $"{type.Name}.{property.Name} is registered as Stabilized and should have [JsonPropertyName]."); + + // Field must have [JsonIgnore] + Assert.True(field.GetCustomAttribute() is not null, + $"{type.Name}.{fieldName} is registered as Stabilized and should have [JsonIgnore]."); + + // Field must have [Obsolete] + Assert.True(HasAttribute(field, "ObsoleteAttribute"), + $"{type.Name}.{fieldName} is registered as Stabilized and should have [Obsolete]."); + } + + /// + /// Checks for an attribute by name to avoid CS0436 conflicts with polyfill types on net472. + /// + private static bool HasAttribute(MemberInfo member, string attributeName) => + member.CustomAttributes.Any(a => a.AttributeType.Name == attributeName); + + /// Base type for entries in the experimental property registry. + private abstract record ExperimentalPropertyEntry(Type Type, string PropertyName); + + /// + /// An experimental property with a backing field that handles serialization. + /// The property has [Experimental] + [JsonIgnore]. The backing field has + /// [JsonInclude], [JsonPropertyName], [JsonConverter], and [EditorBrowsable(Never)]. + /// + private record ExperimentalProperty(Type Type, string PropertyName, string FieldName) + : ExperimentalPropertyEntry(Type, PropertyName); + + /// + /// A recently-stabilized property whose backing field must remain for binary compatibility. + /// The property has [JsonPropertyName] (no [Experimental], no [JsonIgnore]). + /// The backing field has [JsonIgnore], [Obsolete], and [EditorBrowsable(Never)]. + /// + private record StabilizedProperty(Type Type, string PropertyName, string FieldName) + : ExperimentalPropertyEntry(Type, PropertyName); + + /// + /// An experimental property that is excluded from backing field validation. It has [Experimental] + /// and [JsonIgnore] but does not require a backing field. The must + /// justify why this property is excluded. + /// + private record IgnoredExperimentalProperty(Type Type, string PropertyName, string Reason) + : ExperimentalPropertyEntry(Type, PropertyName); +} diff --git a/tests/ModelContextProtocol.Tests/ExperimentalJsonConverterTests.cs b/tests/ModelContextProtocol.Tests/ExperimentalJsonConverterTests.cs new file mode 100644 index 000000000..04a6117d0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/ExperimentalJsonConverterTests.cs @@ -0,0 +1,186 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Tests; + +public static class ExperimentalJsonConverterTests +{ + [Fact] + public static void Tool_WithExecution_RoundTrips() + { + var original = new Tool + { + Name = "test-tool", + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("test-tool", deserialized.Name); + Assert.NotNull(deserialized.Execution); + Assert.Equal(ToolTaskSupport.Optional, deserialized.Execution.TaskSupport); + } + + [Fact] + public static void Tool_WithNullExecution_RoundTrips() + { + var original = new Tool { Name = "simple-tool" }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Null(deserialized.Execution); + Assert.DoesNotContain("execution", json); + } + + [Fact] + public static void ServerCapabilities_WithTasks_RoundTrips() + { + var original = new ServerCapabilities + { + Tasks = new McpTasksCapability() + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Tasks); + } + + [Fact] + public static void ServerCapabilities_WithNullTasks_OmitsProperty() + { + var original = new ServerCapabilities(); + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + + Assert.DoesNotContain("tasks", json); + } + + [Fact] + public static void ClientCapabilities_WithTasks_RoundTrips() + { + var original = new ClientCapabilities + { + Tasks = new McpTasksCapability() + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Tasks); + } + + [Fact] + public static void CallToolResult_WithTask_RoundTrips() + { + var original = new CallToolResult + { + Task = new McpTask + { + TaskId = "task-123", + Status = McpTaskStatus.Working, + CreatedAt = DateTimeOffset.UtcNow, + LastUpdatedAt = DateTimeOffset.UtcNow, + } + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Task); + Assert.Equal("task-123", deserialized.Task.TaskId); + Assert.Equal(McpTaskStatus.Working, deserialized.Task.Status); + } + + [Fact] + public static void CallToolRequestParams_WithTask_RoundTrips() + { + var original = new CallToolRequestParams + { + Name = "my-tool", + Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(5) } + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.Equal("my-tool", deserialized.Name); + Assert.NotNull(deserialized.Task); + Assert.Equal(TimeSpan.FromMinutes(5), deserialized.Task.TimeToLive); + } + + [Fact] + public static void CreateMessageRequestParams_WithTask_RoundTrips() + { + var original = new CreateMessageRequestParams + { + Messages = [], + MaxTokens = 100, + Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) } + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Task); + Assert.Equal(TimeSpan.FromMinutes(10), deserialized.Task.TimeToLive); + } + + [Fact] + public static void ElicitRequestParams_WithTask_RoundTrips() + { + var original = new ElicitRequestParams + { + Message = "test prompt", + Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(15) } + }; + + string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.Task); + Assert.Equal(TimeSpan.FromMinutes(15), deserialized.Task.TimeToLive); + } + + [Fact] + public static void Tool_WithExecution_JsonPropertyNameIsCorrect() + { + var tool = new Tool + { + Name = "test", + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required } + }; + + string json = JsonSerializer.Serialize(tool, McpJsonUtilities.DefaultOptions); + + Assert.Contains("\"execution\"", json); + Assert.Contains("\"taskSupport\"", json); + } + + [Fact] + public static void Tool_WriteIndented_IsRespected() + { + var tool = new Tool + { + Name = "test", + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } + }; + + // Use caller options with WriteIndented = true + var options = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions) { WriteIndented = true }; + string json = JsonSerializer.Serialize(tool, options); + + // The output should be indented because WriteIndented is controlled by the writer + Assert.Contains("\n", json); + } +}