Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ModelContextProtocol.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,6 @@
<Project Path="tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj" />
<Project Path="tests/ModelContextProtocol.TestServer/ModelContextProtocol.TestServer.csproj" />
<Project Path="tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj" />
<Project Path="tests/ModelContextProtocol.SuppressorRegressionTest/ModelContextProtocol.SuppressorRegressionTest.csproj" />
</Folder>
</Solution>
51 changes: 51 additions & 0 deletions src/ModelContextProtocol.Analyzers/MCPEXP001Suppressor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;

namespace ModelContextProtocol.Analyzers;

/// <summary>
/// Suppresses MCPEXP001 diagnostics in source-generated code.
/// </summary>
/// <remarks>
/// <para>
/// The MCP SDK uses internal serialization properties to handle serialization of experimental types.
/// When consumers define their own <c>JsonSerializerContext</c>, the System.Text.Json source generator
/// on .NET 8 and .NET 9 emits property metadata referencing experimental types (even for
/// <c>[JsonIgnore]</c> properties), which triggers MCPEXP001 diagnostics in the generated code.
/// </para>
/// <para>
/// This suppressor suppresses MCPEXP001 only in source-generated files (identified by <c>.g.cs</c> file extension),
/// so that hand-written user code that directly references experimental types still produces the diagnostic.
/// </para>
/// </remarks>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class MCPEXP001Suppressor : DiagnosticSuppressor
Comment thread
MackinnonBuck marked this conversation as resolved.
Outdated
{
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.");

/// <inheritdoc/>
public override ImmutableArray<SuppressionDescriptor> SupportedSuppressions =>
ImmutableArray.Create(SuppressInGeneratedCode);

/// <inheritdoc/>
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);
}
Comment thread
MackinnonBuck marked this conversation as resolved.
Outdated
}
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

Expand Down Expand Up @@ -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.
/// </remarks>
[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; }
Comment thread
jeffhandley marked this conversation as resolved.
}
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/CallToolResult.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

Expand Down Expand Up @@ -65,6 +66,16 @@ public sealed class CallToolResult : Result
/// (<see cref="Content"/>, <see cref="StructuredContent"/>, <see cref="IsError"/>) may not be populated.
/// The actual tool result can be retrieved later via <c>tasks/result</c>.
/// </remarks>
[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; }
}
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
Expand Down Expand Up @@ -80,6 +81,16 @@ public sealed class ClientCapabilities
/// See <see cref="McpTasksCapability"/> for details on configuring which operations support tasks.
/// </para>
/// </remarks>
[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]
Comment thread
MackinnonBuck marked this conversation as resolved.
[JsonPropertyName("tasks")]
public McpTasksCapability? Tasks { get; set; }
internal McpTasksCapability? TasksCore { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

Expand Down Expand Up @@ -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.
/// </remarks>
[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; }
}
12 changes: 11 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </remarks>
[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; }

/// <summary>Represents a request schema used in a form mode elicitation request.</summary>
public sealed class RequestSchema
Expand Down
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ModelContextProtocol.Server;

Expand Down Expand Up @@ -79,6 +80,16 @@ public sealed class ServerCapabilities
/// See <see cref="McpTasksCapability"/> for details on configuring which operations support tasks.
/// </para>
/// </remarks>
[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; }
}
11 changes: 10 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/Tool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,17 @@ public JsonElement? OutputSchema
/// regarding task augmentation support. See <see cref="ToolExecution"/> for details.
/// </remarks>
[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; }

/// <summary>
/// Gets or sets an optional list of icons for this tool.
Expand Down
159 changes: 159 additions & 0 deletions tests/ModelContextProtocol.Analyzers.Tests/MCPEXP001SuppressorTests.cs
Original file line number Diff line number Diff line change
@@ -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<SuppressorResult> RunSuppressorAsync(
string source,
string filePath,
string additionalSource,
string additionalFilePath)
{
return RunSuppressorAsync([(additionalSource, additionalFilePath), (source, filePath)]);
}

private static async Task<SuppressorResult> 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<MetadataReference> 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<DiagnosticAnalyzer>(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<Diagnostic> BeforeSuppression { get; set; } = [];
public ImmutableArray<Diagnostic> AfterSuppression { get; set; } = [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
using ModelContextProtocol.Protocol;

namespace ModelContextProtocol.SuppressorRegressionTest;

/// <summary>
/// This file validates that the MCPEXP001 diagnostic suppressor works correctly.
/// By including MCP protocol types that have experimental properties in a
/// <see cref="JsonSerializerContext"/>, 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.
/// </summary>
[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;
Loading