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