diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 3036f301e..1090c5377 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -78,5 +78,6 @@ + diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs index fa6939d7a..8267cd06f 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -33,6 +34,16 @@ 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 => TaskCore; + set => TaskCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] [JsonPropertyName("task")] - public McpTaskMetadata? Task { get; set; } + internal McpTaskMetadata? TaskCore { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs index 509436010..e40dc560a 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResult.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -65,6 +66,16 @@ 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 => TaskCore; + set => TaskCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] [JsonPropertyName("task")] - public McpTask? Task { get; set; } + internal McpTask? TaskCore { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs index cb85ef5e3..9841e3da0 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,16 @@ 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 => TasksCore; + set => TasksCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] [JsonPropertyName("tasks")] - public McpTasksCapability? Tasks { get; set; } + internal McpTasksCapability? TasksCore { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs index 59be7fab8..eba472246 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Serialization; @@ -128,6 +129,16 @@ 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 => TaskCore; + set => TaskCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] [JsonPropertyName("task")] - public McpTaskMetadata? Task { get; set; } + internal McpTaskMetadata? TaskCore { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs index 91cbb307d..921008574 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs @@ -99,8 +99,18 @@ 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 => TaskCore; + set => TaskCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] [JsonPropertyName("task")] - public McpTaskMetadata? Task { get; set; } + internal McpTaskMetadata? TaskCore { get; set; } /// 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..d4e55653e 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,16 @@ 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 => TasksCore; + set => TasksCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] [JsonPropertyName("tasks")] - public McpTasksCapability? Tasks { get; set; } + internal McpTasksCapability? TasksCore { get; set; } } diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs index 8e06d7104..473b70f25 100644 --- a/src/ModelContextProtocol.Core/Protocol/Tool.cs +++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs @@ -120,8 +120,17 @@ public JsonElement? OutputSchema /// regarding task augmentation support. See for details. /// [Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)] + [JsonIgnore] + public ToolExecution? Execution + { + get => ExecutionCore; + set => ExecutionCore = value; + } + + // See ExperimentalInternalPropertyTests.cs before modifying this property. + [JsonInclude] [JsonPropertyName("execution")] - public ToolExecution? Execution { get; set; } + internal ToolExecution? ExecutionCore { get; set; } /// /// Gets or sets an optional list of icons for this tool. diff --git a/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs new file mode 100644 index 000000000..d30036822 --- /dev/null +++ b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.ExperimentalApiRegressionTest; + +/// +/// This file validates that the System.Text.Json source generator does not produce +/// MCPEXP001 diagnostics for MCP protocol types with experimental properties. +/// +[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.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj new file mode 100644 index 000000000..fb2423548 --- /dev/null +++ b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj @@ -0,0 +1,29 @@ + + + + net10.0;net9.0;net8.0 + enable + enable + false + + + $(NoWarn.Replace('MCPEXP001','')) + + + $(NoWarn);SYSLIB1038 + + + + + + + diff --git a/tests/ModelContextProtocol.Tests/ExperimentalInternalPropertyTests.cs b/tests/ModelContextProtocol.Tests/ExperimentalInternalPropertyTests.cs new file mode 100644 index 000000000..17e18dc2b --- /dev/null +++ b/tests/ModelContextProtocol.Tests/ExperimentalInternalPropertyTests.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Tests; + +/// +/// Validates the internal property pattern used for experimental MCP properties. +/// +/// +/// Experimental properties on stable protocol types must use an internal serialization +/// property to hide the experimental type from external source generators. The public +/// property is marked [Experimental][JsonIgnore] and delegates to an internal *Core +/// property marked [JsonInclude][JsonPropertyName]. +/// +public class ExperimentalInternalPropertyTests +{ + [Fact] + public void ExperimentalProperties_MustBeHiddenFromSourceGenerator() + { + // [Experimental] properties on stable protocol types must use the internal property + // pattern so the STJ source generator does not reference experimental types in + // generated code (which would trigger MCPEXP001 for consumers). + // + // Required pattern: + // 1. Mark the public property [Experimental][JsonIgnore] + // 2. Add an internal *Core property with [JsonInclude][JsonPropertyName] + // that the public property delegates to + // + // To stabilize: + // 1. Remove [Experimental] and [JsonIgnore] from the public property + // 2. Add [JsonPropertyName] to the public property + // 3. Convert to auto-property + // 4. Remove the internal *Core property + + foreach (var (type, prop) in GetExperimentalPropertiesOnStableTypes()) + { + Assert.True( + prop.GetCustomAttribute() is not null, + $"{type.Name}.{prop.Name} is [Experimental] but missing [JsonIgnore]."); + + Assert.True( + prop.GetCustomAttribute() is null, + $"{type.Name}.{prop.Name} is [Experimental] and must not have [JsonPropertyName]."); + } + } + + private static IEnumerable<(Type Type, PropertyInfo Property)> GetExperimentalPropertiesOnStableTypes() + { + var protocolTypes = typeof(Tool).Assembly.GetTypes() + .Where(t => t.Namespace == "ModelContextProtocol.Protocol" && t.IsClass && !t.IsAbstract); + + foreach (var type in protocolTypes) + { + if (type.GetCustomAttributes().Any(a => a.GetType().Name == "ExperimentalAttribute")) + { + continue; + } + + foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + { + if (prop.GetCustomAttributes().Any(a => a.GetType().Name == "ExperimentalAttribute")) + { + yield return (type, prop); + } + } + } + } +} diff --git a/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs b/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs new file mode 100644 index 000000000..d68902ef5 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs @@ -0,0 +1,118 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Tests; + +/// +/// Validates that the internal property pattern used for experimental properties +/// produces the expected serialization behavior for SDK consumers using source generators. +/// +/// +/// +/// Experimental properties (e.g. , ) +/// use an internal *Core property for serialization. A consumer's source-generated +/// cannot see internal members, so experimental data is +/// silently dropped unless the consumer chains the SDK's resolver into their options. +/// +/// +/// These tests depend on and +/// being experimental. When those APIs stabilize, update these tests to reference whatever +/// experimental properties exist at that time, or remove them entirely if no experimental +/// APIs remain. +/// +/// +public class ExperimentalPropertySerializationTests +{ + [Fact] + public void ExperimentalProperties_Dropped_WithConsumerContextOnly() + { + var options = new JsonSerializerOptions + { + TypeInfoResolverChain = { ConsumerJsonContext.Default } + }; + + var tool = new Tool + { + Name = "test-tool", + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } + }; + + string json = JsonSerializer.Serialize(tool, options); + Assert.DoesNotContain("\"execution\"", json); + Assert.Contains("\"name\"", json); + } + + [Fact] + public void ExperimentalProperties_IgnoredOnDeserialize_WithConsumerContextOnly() + { + string json = JsonSerializer.Serialize( + new Tool + { + Name = "test-tool", + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } + }, + McpJsonUtilities.DefaultOptions); + Assert.Contains("\"execution\"", json); + + var options = new JsonSerializerOptions + { + TypeInfoResolverChain = { ConsumerJsonContext.Default } + }; + var deserialized = JsonSerializer.Deserialize(json, options)!; + Assert.Equal("test-tool", deserialized.Name); + Assert.Null(deserialized.Execution); + } + + [Fact] + public void ExperimentalProperties_RoundTrip_WhenSdkResolverIsChained() + { + var options = new JsonSerializerOptions + { + TypeInfoResolverChain = + { + McpJsonUtilities.DefaultOptions.TypeInfoResolver!, + ConsumerJsonContext.Default, + } + }; + + var tool = new Tool + { + Name = "test-tool", + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional } + }; + + string json = JsonSerializer.Serialize(tool, options); + Assert.Contains("\"execution\"", json); + Assert.Contains("\"name\"", json); + + var deserialized = JsonSerializer.Deserialize(json, options)!; + Assert.Equal("test-tool", deserialized.Name); + Assert.NotNull(deserialized.Execution); + Assert.Equal(ToolTaskSupport.Optional, deserialized.Execution.TaskSupport); + } + + [Fact] + public void ExperimentalProperties_RoundTrip_WithDefaultOptions() + { + var capabilities = new ServerCapabilities + { + Tasks = new McpTasksCapability() + }; + + string json = JsonSerializer.Serialize(capabilities, McpJsonUtilities.DefaultOptions); + Assert.Contains("\"tasks\"", json); + + var deserialized = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions)!; + Assert.NotNull(deserialized.Tasks); + } +} + +[JsonSerializable(typeof(Tool))] +[JsonSerializable(typeof(ServerCapabilities))] +[JsonSerializable(typeof(ClientCapabilities))] +[JsonSerializable(typeof(CallToolResult))] +[JsonSerializable(typeof(CallToolRequestParams))] +[JsonSerializable(typeof(CreateMessageRequestParams))] +[JsonSerializable(typeof(ElicitRequestParams))] +internal partial class ConsumerJsonContext : JsonSerializerContext;