From 17d459a629fcac324d164dcf90fb2a65f88f091b Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 17 Feb 2026 13:50:34 -0800 Subject: [PATCH 1/3] Use internal property pattern to hide experimental types from external source generators Experimental properties on stable protocol types cause external STJ source generators to emit code referencing experimental types, creating binary compatibility risks and unwanted MCPEXP001 diagnostics for consumers who don't use those APIs. This commit addresses both concerns: - Internal property pattern: each experimental property delegates to an internal *Core property with [JsonInclude][JsonPropertyName]. External source generators cannot see internal members, so only the SDK's own McpJsonUtilities.DefaultOptions serializes these properties. This eliminates binary coupling to experimental types across assemblies. - MCPEXP001Suppressor: on net8.0/net9.0, the SG still references experimental types in [JsonIgnore] property metadata (fixed in net10.0 by dotnet/runtime#120181). The suppressor silences MCPEXP001 in .g.cs files so it only surfaces in hand-written code. Applied to all 7 experimental properties: Tool.Execution, ServerCapabilities.Tasks, ClientCapabilities.Tasks, CallToolResult.Task, CallToolRequestParams.Task, CreateMessageRequestParams.Task, ElicitRequestParams.Task. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ModelContextProtocol.slnx | 1 + .../MCPEXP001Suppressor.cs | 51 ++++++ .../Protocol/CallToolRequestParams.cs | 13 +- .../Protocol/CallToolResult.cs | 13 +- .../Protocol/ClientCapabilities.cs | 13 +- .../Protocol/CreateMessageRequestParams.cs | 13 +- .../Protocol/ElicitRequestParams.cs | 12 +- .../Protocol/ServerCapabilities.cs | 13 +- .../Protocol/Tool.cs | 11 +- .../MCPEXP001SuppressorTests.cs | 159 ++++++++++++++++++ .../ExperimentalPropertyRegressionContext.cs | 20 +++ ...xtProtocol.SuppressorRegressionTest.csproj | 26 +++ .../ExperimentalInternalPropertyTests.cs | 69 ++++++++ 13 files changed, 407 insertions(+), 7 deletions(-) create mode 100644 src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs create mode 100644 tests/ModelContextProtocol.Analyzers.Tests/MCPEXP001SuppressorTests.cs create mode 100644 tests/ModelContextProtocol.SuppressorRegressionTest/ExperimentalPropertyRegressionContext.cs create mode 100644 tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj create mode 100644 tests/ModelContextProtocol.Tests/ExperimentalInternalPropertyTests.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 3036f301e..1a3de723f 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -78,5 +78,6 @@ + diff --git a/src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs b/src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs new file mode 100644 index 000000000..feb0a37a5 --- /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 internal serialization properties to handle serialization of experimental types. +/// When consumers define their own JsonSerializerContext, the System.Text.Json source generator +/// on .NET 8 and .NET 9 emits property metadata referencing experimental types (even for +/// [JsonIgnore] properties), 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 internal serialization 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/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.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..53e03a76d --- /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 properties 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..a49148047 --- /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/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); + } + } + } + } +} From fe5848dc968ccb922a796f89d3ea62e7310f1bb3 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Tue, 17 Feb 2026 15:34:19 -0800 Subject: [PATCH 2/3] Add for regression test project --- .../ModelContextProtocol.SuppressorRegressionTest.csproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj b/tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj index a49148047..c071c4918 100644 --- a/tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj +++ b/tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj @@ -14,6 +14,13 @@ from the source-generated code that references experimental types. --> $(NoWarn.Replace('MCPEXP001','')) + + + $(NoWarn);SYSLIB1038 From dc160567dfb92b7eb11a6083371a8e2d5b910f63 Mon Sep 17 00:00:00 2001 From: Mackinnon Buck Date: Wed, 18 Feb 2026 13:12:38 -0800 Subject: [PATCH 3/3] Remove diagnostic suppressor --- ModelContextProtocol.slnx | 2 +- .../MCPEXP001Suppressor.cs | 51 ------ .../MCPEXP001SuppressorTests.cs | 159 ------------------ .../ExperimentalPropertyRegressionContext.cs | 9 +- ...ocol.ExperimentalApiRegressionTest.csproj} | 8 +- .../ExperimentalPropertySerializationTests.cs | 118 +++++++++++++ 6 files changed, 124 insertions(+), 223 deletions(-) delete mode 100644 src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs delete mode 100644 tests/ModelContextProtocol.Analyzers.Tests/MCPEXP001SuppressorTests.cs rename tests/{ModelContextProtocol.SuppressorRegressionTest => ModelContextProtocol.ExperimentalApiRegressionTest}/ExperimentalPropertyRegressionContext.cs (54%) rename tests/{ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj => ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj} (67%) create mode 100644 tests/ModelContextProtocol.Tests/ExperimentalPropertySerializationTests.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 1a3de723f..1090c5377 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -78,6 +78,6 @@ - + diff --git a/src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs b/src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs deleted file mode 100644 index feb0a37a5..000000000 --- a/src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs +++ /dev/null @@ -1,51 +0,0 @@ -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 internal serialization properties to handle serialization of experimental types. -/// When consumers define their own JsonSerializerContext, the System.Text.Json source generator -/// on .NET 8 and .NET 9 emits property metadata referencing experimental types (even for -/// [JsonIgnore] properties), 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 internal serialization 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/tests/ModelContextProtocol.Analyzers.Tests/MCPEXP001SuppressorTests.cs b/tests/ModelContextProtocol.Analyzers.Tests/MCPEXP001SuppressorTests.cs deleted file mode 100644 index 2e622f4bb..000000000 --- a/tests/ModelContextProtocol.Analyzers.Tests/MCPEXP001SuppressorTests.cs +++ /dev/null @@ -1,159 +0,0 @@ -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.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs similarity index 54% rename from tests/ModelContextProtocol.SuppressorRegressionTest/ExperimentalPropertyRegressionContext.cs rename to tests/ModelContextProtocol.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs index 53e03a76d..d30036822 100644 --- a/tests/ModelContextProtocol.SuppressorRegressionTest/ExperimentalPropertyRegressionContext.cs +++ b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ExperimentalPropertyRegressionContext.cs @@ -1,14 +1,11 @@ using System.Text.Json.Serialization; using ModelContextProtocol.Protocol; -namespace ModelContextProtocol.SuppressorRegressionTest; +namespace ModelContextProtocol.ExperimentalApiRegressionTest; /// -/// This file validates that the MCPEXP001 diagnostic suppressor works correctly. -/// By including MCP protocol types that have experimental properties 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. +/// 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))] diff --git a/tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj similarity index 67% rename from tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj rename to tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj index c071c4918..fb2423548 100644 --- a/tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj +++ b/tests/ModelContextProtocol.ExperimentalApiRegressionTest/ModelContextProtocol.ExperimentalApiRegressionTest.csproj @@ -7,11 +7,10 @@ false $(NoWarn.Replace('MCPEXP001','')) @@ -25,9 +24,6 @@ - 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;