Hide experimental types from external source generators using internal property pattern (#1301)

This commit is contained in:
Mackinnon Buck 2026-02-19 07:27:30 -08:00 • committed by GitHub
commit cc6ef5b926
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 315 additions and 7 deletions

View file

@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.ExperimentalApiRegressionTest;
/// <summary>
/// This file validates that the System.Text.Json source generator does not produce
/// MCPEXP001 diagnostics for MCP protocol types with experimental properties.
/// </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;

View file

@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!--
This project validates that MCPEXP001 diagnostics are NOT emitted by the
System.Text.Json source generator for types with experimental properties.
tests/Directory.Build.props suppresses MCPEXP001 globally for all test projects,
but this project must NOT suppress it: its successful compilation IS the test.
-->
<NoWarn>$(NoWarn.Replace('MCPEXP001',''))</NoWarn>
<!--
[JsonInclude] internal members trigger this warning only because the MCP C# SDK
is a project reference here. NuGet consumers won't see it since Roslyn doesn't import
internal members from metadata.
-->
<NoWarn>$(NoWarn);SYSLIB1038</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,69 @@
using System.Reflection;
using System.Text.Json.Serialization;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Tests;
/// <summary>
/// Validates the internal property pattern used for experimental MCP properties.
/// </summary>
/// <remarks>
/// 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].
/// </remarks>
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<JsonIgnoreAttribute>() is not null,
$"{type.Name}.{prop.Name} is [Experimental] but missing [JsonIgnore].");
Assert.True(
prop.GetCustomAttribute<JsonPropertyNameAttribute>() 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);
}
}
}
}
}

View file

@ -0,0 +1,118 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ModelContextProtocol.Protocol;
namespace ModelContextProtocol.Tests;
/// <summary>
/// Validates that the internal property pattern used for experimental properties
/// produces the expected serialization behavior for SDK consumers using source generators.
/// </summary>
/// <remarks>
/// <para>
/// Experimental properties (e.g. <see cref="Tool.Execution"/>, <see cref="ServerCapabilities.Tasks"/>)
/// use an internal <c>*Core</c> property for serialization. A consumer's source-generated
/// <see cref="JsonSerializerContext"/> cannot see internal members, so experimental data is
/// silently dropped unless the consumer chains the SDK's resolver into their options.
/// </para>
/// <para>
/// These tests depend on <see cref="Tool.Execution"/> and <see cref="ServerCapabilities.Tasks"/>
/// 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.
/// </para>
/// </remarks>
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<Tool>(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<Tool>(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<ServerCapabilities>(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;