Skip to content

Commit 17d459a

Browse files
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>
1 parent 392e47e commit 17d459a

File tree

13 files changed

+407
-7
lines changed

13 files changed

+407
-7
lines changed

ModelContextProtocol.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,5 +78,6 @@
7878
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />
7979
<Project Path="tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj" />
8080
<Project Path="tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj" />
81+
<Project Path="tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj" />
8182
</Folder>
8283
</Solution>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.Diagnostics;
3+
using System.Collections.Immutable;
4+
5+
namespace ModelContextProtocol.Analyzers;
6+
7+
/// <summary>
8+
/// Suppresses MCPEXP001 diagnostics in source-generated code.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// The MCP SDK uses internal serialization properties to handle serialization of experimental types.
13+
/// When consumers define their own <c>JsonSerializerContext</c>, the System.Text.Json source generator
14+
/// on .NET 8 and .NET 9 emits property metadata referencing experimental types (even for
15+
/// <c>[JsonIgnore]</c> properties), which triggers MCPEXP001 diagnostics in the generated code.
16+
/// </para>
17+
/// <para>
18+
/// This suppressor suppresses MCPEXP001 only in source-generated files (identified by <c>.g.cs</c> file extension),
19+
/// so that hand-written user code that directly references experimental types still produces the diagnostic.
20+
/// </para>
21+
/// </remarks>
22+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
23+
public sealed class MCPEXP001Suppressor : DiagnosticSuppressor
24+
{
25+
private static readonly SuppressionDescriptor SuppressInGeneratedCode = new(
26+
id: "MCP_MCPEXP001_GENERATED",
27+
suppressedDiagnosticId: "MCPEXP001",
28+
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.");
29+
30+
/// <inheritdoc/>
31+
public override ImmutableArray<SuppressionDescriptor> SupportedSuppressions =>
32+
ImmutableArray.Create(SuppressInGeneratedCode);
33+
34+
/// <inheritdoc/>
35+
public override void ReportSuppressions(SuppressionAnalysisContext context)
36+
{
37+
foreach (Diagnostic diagnostic in context.ReportedDiagnostics)
38+
{
39+
if (diagnostic.Id == "MCPEXP001" && IsInGeneratedCode(diagnostic))
40+
{
41+
context.ReportSuppression(Suppression.Create(SuppressInGeneratedCode, diagnostic));
42+
}
43+
}
44+
}
45+
46+
private static bool IsInGeneratedCode(Diagnostic diagnostic)
47+
{
48+
string? filePath = diagnostic.Location.SourceTree?.FilePath;
49+
return filePath is not null && filePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase);
50+
}
51+
}

src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using System.Text.Json;
23
using System.Text.Json.Serialization;
34

@@ -33,6 +34,16 @@ public sealed class CallToolRequestParams : RequestParams
3334
/// When present, indicates that the requestor wants this operation executed as a task.
3435
/// The receiver must support task augmentation for this specific request type.
3536
/// </remarks>
37+
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
38+
[JsonIgnore]
39+
public McpTaskMetadata? Task
40+
{
41+
get => TaskCore;
42+
set => TaskCore = value;
43+
}
44+
45+
// See ExperimentalInternalPropertyTests.cs before modifying this property.
46+
[JsonInclude]
3647
[JsonPropertyName("task")]
37-
public McpTaskMetadata? Task { get; set; }
48+
internal McpTaskMetadata? TaskCore { get; set; }
3849
}

src/ModelContextProtocol.Core/Protocol/CallToolResult.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using System.Text.Json.Nodes;
23
using System.Text.Json.Serialization;
34

@@ -65,6 +66,16 @@ public sealed class CallToolResult : Result
6566
/// (<see cref="Content"/>, <see cref="StructuredContent"/>, <see cref="IsError"/>) may not be populated.
6667
/// The actual tool result can be retrieved later via <c>tasks/result</c>.
6768
/// </remarks>
69+
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
70+
[JsonIgnore]
71+
public McpTask? Task
72+
{
73+
get => TaskCore;
74+
set => TaskCore = value;
75+
}
76+
77+
// See ExperimentalInternalPropertyTests.cs before modifying this property.
78+
[JsonInclude]
6879
[JsonPropertyName("task")]
69-
public McpTask? Task { get; set; }
80+
internal McpTask? TaskCore { get; set; }
7081
}

src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.ComponentModel;
2+
using System.Diagnostics.CodeAnalysis;
23
using System.Text.Json.Serialization;
34
using ModelContextProtocol.Client;
45
using ModelContextProtocol.Server;
@@ -80,6 +81,16 @@ public sealed class ClientCapabilities
8081
/// See <see cref="McpTasksCapability"/> for details on configuring which operations support tasks.
8182
/// </para>
8283
/// </remarks>
84+
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
85+
[JsonIgnore]
86+
public McpTasksCapability? Tasks
87+
{
88+
get => TasksCore;
89+
set => TasksCore = value;
90+
}
91+
92+
// See ExperimentalInternalPropertyTests.cs before modifying this property.
93+
[JsonInclude]
8394
[JsonPropertyName("tasks")]
84-
public McpTasksCapability? Tasks { get; set; }
95+
internal McpTasksCapability? TasksCore { get; set; }
8596
}

src/ModelContextProtocol.Core/Protocol/CreateMessageRequestParams.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using System.Text.Json;
23
using System.Text.Json.Serialization;
34

@@ -128,6 +129,16 @@ public sealed class CreateMessageRequestParams : RequestParams
128129
/// When present, indicates that the requestor wants this operation executed as a task.
129130
/// The receiver must support task augmentation for this specific request type.
130131
/// </remarks>
132+
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
133+
[JsonIgnore]
134+
public McpTaskMetadata? Task
135+
{
136+
get => TaskCore;
137+
set => TaskCore = value;
138+
}
139+
140+
// See ExperimentalInternalPropertyTests.cs before modifying this property.
141+
[JsonInclude]
131142
[JsonPropertyName("task")]
132-
public McpTaskMetadata? Task { get; set; }
143+
internal McpTaskMetadata? TaskCore { get; set; }
133144
}

src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,18 @@ public string Mode
9999
/// When present, indicates that the requestor wants this operation executed as a task.
100100
/// The receiver must support task augmentation for this specific request type.
101101
/// </remarks>
102+
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
103+
[JsonIgnore]
104+
public McpTaskMetadata? Task
105+
{
106+
get => TaskCore;
107+
set => TaskCore = value;
108+
}
109+
110+
// See ExperimentalInternalPropertyTests.cs before modifying this property.
111+
[JsonInclude]
102112
[JsonPropertyName("task")]
103-
public McpTaskMetadata? Task { get; set; }
113+
internal McpTaskMetadata? TaskCore { get; set; }
104114

105115
/// <summary>Represents a request schema used in a form mode elicitation request.</summary>
106116
public sealed class RequestSchema

src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.ComponentModel;
2+
using System.Diagnostics.CodeAnalysis;
23
using System.Text.Json.Serialization;
34
using ModelContextProtocol.Server;
45

@@ -79,6 +80,16 @@ public sealed class ServerCapabilities
7980
/// See <see cref="McpTasksCapability"/> for details on configuring which operations support tasks.
8081
/// </para>
8182
/// </remarks>
83+
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
84+
[JsonIgnore]
85+
public McpTasksCapability? Tasks
86+
{
87+
get => TasksCore;
88+
set => TasksCore = value;
89+
}
90+
91+
// See ExperimentalInternalPropertyTests.cs before modifying this property.
92+
[JsonInclude]
8293
[JsonPropertyName("tasks")]
83-
public McpTasksCapability? Tasks { get; set; }
94+
internal McpTasksCapability? TasksCore { get; set; }
8495
}

src/ModelContextProtocol.Core/Protocol/Tool.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,8 +120,17 @@ public JsonElement? OutputSchema
120120
/// regarding task augmentation support. See <see cref="ToolExecution"/> for details.
121121
/// </remarks>
122122
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
123+
[JsonIgnore]
124+
public ToolExecution? Execution
125+
{
126+
get => ExecutionCore;
127+
set => ExecutionCore = value;
128+
}
129+
130+
// See ExperimentalInternalPropertyTests.cs before modifying this property.
131+
[JsonInclude]
123132
[JsonPropertyName("execution")]
124-
public ToolExecution? Execution { get; set; }
133+
internal ToolExecution? ExecutionCore { get; set; }
125134

126135
/// <summary>
127136
/// Gets or sets an optional list of icons for this tool.
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
using Microsoft.CodeAnalysis;
2+
using Microsoft.CodeAnalysis.CSharp;
3+
using Microsoft.CodeAnalysis.Diagnostics;
4+
using System.Collections.Immutable;
5+
using Xunit;
6+
7+
namespace ModelContextProtocol.Analyzers.Tests;
8+
9+
public class MCPEXP001SuppressorTests
10+
{
11+
[Fact]
12+
public async Task Suppressor_InGeneratedCode_SuppressesMCPEXP001()
13+
{
14+
// Simulate source-generated code (e.g., STJ source gen) that references an experimental type.
15+
// The file path ends with .g.cs to indicate it's generated.
16+
var result = await RunSuppressorAsync(
17+
source: """
18+
using ExperimentalTypes;
19+
20+
namespace Generated
21+
{
22+
public static class SerializerHelper
23+
{
24+
public static object Create() => new ExperimentalClass();
25+
}
26+
}
27+
""",
28+
filePath: "Generated.g.cs",
29+
additionalSource: GetExperimentalTypeDefinition(),
30+
additionalFilePath: "ExperimentalTypes.cs");
31+
32+
// MCPEXP001 should exist before the suppressor runs
33+
Assert.Contains(result.BeforeSuppression, d => d.Id == "MCPEXP001");
34+
35+
// After suppression, MCPEXP001 should be gone from the results
36+
Assert.DoesNotContain(result.AfterSuppression, d => d.Id == "MCPEXP001");
37+
}
38+
39+
[Fact]
40+
public async Task Suppressor_InHandWrittenCode_DoesNotSuppressMCPEXP001()
41+
{
42+
// Hand-written user code referencing an experimental type.
43+
// The file path does NOT end with .g.cs.
44+
var result = await RunSuppressorAsync(
45+
source: """
46+
using ExperimentalTypes;
47+
48+
namespace UserCode
49+
{
50+
public static class MyHelper
51+
{
52+
public static object Create() => new ExperimentalClass();
53+
}
54+
}
55+
""",
56+
filePath: "MyHelper.cs",
57+
additionalSource: GetExperimentalTypeDefinition(),
58+
additionalFilePath: "ExperimentalTypes.cs");
59+
60+
// MCPEXP001 should exist before the suppressor runs
61+
Assert.Contains(result.BeforeSuppression, d => d.Id == "MCPEXP001");
62+
63+
// It should still be present after the suppressor runs (not suppressed)
64+
Assert.Contains(result.AfterSuppression, d => d.Id == "MCPEXP001");
65+
}
66+
67+
[Fact]
68+
public async Task Suppressor_MixedGeneratedAndHandWritten_OnlySuppressesGenerated()
69+
{
70+
var result = await RunSuppressorAsync(
71+
[
72+
(GetExperimentalTypeDefinition(), "ExperimentalTypes.cs"),
73+
("""
74+
using ExperimentalTypes;
75+
namespace Generated
76+
{
77+
public static class GeneratedHelper
78+
{
79+
public static object Create() => new ExperimentalClass();
80+
}
81+
}
82+
""", "Generated.g.cs"),
83+
("""
84+
using ExperimentalTypes;
85+
namespace UserCode
86+
{
87+
public static class UserHelper
88+
{
89+
public static object Create() => new ExperimentalClass();
90+
}
91+
}
92+
""", "UserCode.cs"),
93+
]);
94+
95+
// Should have MCPEXP001 in both files before suppression
96+
Assert.Equal(2, result.BeforeSuppression.Count(d => d.Id == "MCPEXP001"));
97+
98+
// After suppression: only the hand-written one should remain
99+
var remaining = result.AfterSuppression.Where(d => d.Id == "MCPEXP001").ToList();
100+
Assert.Single(remaining);
101+
Assert.Equal("UserCode.cs", remaining[0].Location.SourceTree?.FilePath);
102+
}
103+
104+
private static string GetExperimentalTypeDefinition() => """
105+
using System.Diagnostics.CodeAnalysis;
106+
107+
namespace ExperimentalTypes
108+
{
109+
[Experimental("MCPEXP001")]
110+
public class ExperimentalClass { }
111+
}
112+
""";
113+
114+
private static Task<SuppressorResult> RunSuppressorAsync(
115+
string source,
116+
string filePath,
117+
string additionalSource,
118+
string additionalFilePath)
119+
{
120+
return RunSuppressorAsync([(additionalSource, additionalFilePath), (source, filePath)]);
121+
}
122+
123+
private static async Task<SuppressorResult> RunSuppressorAsync(params (string Source, string FilePath)[] sources)
124+
{
125+
var syntaxTrees = sources.Select(
126+
s => CSharpSyntaxTree.ParseText(s.Source, path: s.FilePath)).ToArray();
127+
128+
var runtimePath = Path.GetDirectoryName(typeof(object).Assembly.Location)!;
129+
List<MetadataReference> referenceList =
130+
[
131+
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
132+
MetadataReference.CreateFromFile(Path.Combine(runtimePath, "System.Runtime.dll")),
133+
MetadataReference.CreateFromFile(Path.Combine(runtimePath, "netstandard.dll")),
134+
];
135+
136+
var compilation = CSharpCompilation.Create(
137+
"TestAssembly",
138+
syntaxTrees,
139+
referenceList,
140+
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
141+
142+
var beforeSuppression = compilation.GetDiagnostics();
143+
var analyzers = ImmutableArray.Create<DiagnosticAnalyzer>(new MCPEXP001Suppressor());
144+
var compilationWithAnalyzers = compilation.WithAnalyzers(analyzers);
145+
var afterSuppression = await compilationWithAnalyzers.GetAllDiagnosticsAsync(default);
146+
147+
return new SuppressorResult
148+
{
149+
BeforeSuppression = beforeSuppression,
150+
AfterSuppression = afterSuppression,
151+
};
152+
}
153+
154+
private class SuppressorResult
155+
{
156+
public ImmutableArray<Diagnostic> BeforeSuppression { get; set; } = [];
157+
public ImmutableArray<Diagnostic> AfterSuppression { get; set; } = [];
158+
}
159+
}

0 commit comments

Comments
 (0)