Skip to content

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

Open
MackinnonBuck wants to merge 3 commits intomainfrom
mbuck/experimental-json-fix-2
Open

Hide experimental types from external source generators using internal property pattern#1301
MackinnonBuck wants to merge 3 commits intomainfrom
mbuck/experimental-json-fix-2

Conversation

@MackinnonBuck
Copy link
Collaborator

@MackinnonBuck MackinnonBuck commented Feb 17, 2026

Problem

Experimental properties on stable protocol types (e.g. Tool.Execution, ServerCapabilities.Tasks) cause two problems for consumers who don't use those APIs:

  1. Binary compatibility. The STJ source generator emits code that directly references experimental types. If an experimental type is renamed, restructured, or removed in a future SDK version, previously compiled source-generated code breaks, even if the consumer never used the experimental API.
  2. Unwanted diagnostics. The source generator's references to experimental types trigger MCPEXP001 at compile time, forcing consumers to suppress a diagnostic for an API they aren't using.

Approach

Alternative to #1260. Each experimental property is marked [JsonIgnore] and delegates to an internal property marked [JsonInclude][JsonPropertyName]:

[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
[JsonIgnore]
public ToolExecution? Execution
{
    get => ExecutionCore;
    set => ExecutionCore = value;
}

[JsonInclude]
[JsonPropertyName("execution")]
internal ToolExecution? ExecutionCore { get; set; }

This resolves both issues. Because internal members are invisible across assembly boundaries, external source generators never emit code referencing experimental types, eliminating the binary compatibility concern. And because the public property is [JsonIgnore]d, the source generator uses object rather than the experimental type, avoiding the MCPEXP001 diagnostic entirely. The only code path that serializes these properties is the SDK's own McpJsonUtilities.DefaultOptions.

Consumer experience

  • Not using experimental APIs: No diagnostics, no binary coupling to experimental types. Experimental properties are still serialized on the wire through the SDK's own resolver, so protocol compatibility is maintained.
  • Using experimental APIs: Suppress MCPEXP001 as usual. Serialization through McpJsonUtilities.DefaultOptions handles everything.
  • Custom JsonSerializerContext + experimental APIs: Configure TypeInfoResolverChain so the SDK's resolver takes precedence for SDK types. Only needed by consumers who both use experimental APIs and define a custom serialization context.
  • Stabilization: Remove [Experimental]/[JsonIgnore], add [JsonPropertyName], convert to auto-property, delete the *Core property. No consumer changes required.

Changes

  • Applied the internal property pattern to all 7 experimental properties across protocol types
  • Added a compile-time regression test project (ExperimentalApiRegressionTest) that builds with [JsonSerializable] references to all affected types, verifying no MCPEXP001 diagnostics are emitted
  • Added a scan-based test enforcing the internal property pattern across all experimental properties
  • Added serialization round-trip tests verifying behavior with consumer-defined source-generated contexts

Fixes #1255

…l 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>
@MackinnonBuck MackinnonBuck marked this pull request as draft February 17, 2026 22:19
[JsonInclude]
[JsonPropertyName("task")]
public McpTaskMetadata? Task { get; set; }
internal McpTaskMetadata? TaskCore { get; set; }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Since we sometimes use a *Core suffix for protected or internal members, I suggest a suffix with stronger connotation about its purpose, such as Task_Experimental.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we sometimes use a *Core suffix for protected or internal members

Just to clarify, this member is internal, so the name doesn't surface in public API. That said, I'm happy to consider other names if they better convey the purpose of the property.

/// Validates the internal property pattern used for experimental MCP properties.
/// </summary>
/// <remarks>
/// Experimental properties on stable protocol types must use an internal serialization
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can we make sure this is being adequately advertised to users? The fact that we're silently dropping experimental properties only for externally defined source generators (but not externally defined reflection-based JSOs) seems to violate the principle of least astonishment, particularly in the face of us documenting that STJ has the same behavior regardless of what contract resolver is being used.

Given how STJ is being used typically in AOT apps, users could be seeing regressions trigger in unexpected manner, consider the following example:

JsonSerializerOptions options = new()
{
    TypeInfoResolverchain = { MyContext.Default, McpJsonUtilities.DefaultOptions.Resolver }
};

[JsonSerializable(typeof(MyCustomType)]
//[JsonSerializable(typeof(CallToolRequestParams)]
partial class MyContext : JsonSerializerContext;

Either uncommenting CallToolRequestParams or otherwise including it in the transitive type graph of MyContext will result in the experimental properties on CallToolRequestParams getting skipped silently. This seems like a very brittle arrangement to me.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's the unfortunate footgun that this approach has. The brittle configuration is the main tradeoff this PR makes compared to #1260.

I would think that at a minimum, we'd need to document somewhere that anyone serializing MCP protocol types via JsonSerializerContext needs to configure their TypeInfoResolverChain with McpJsonUtilities.DefaultOptions.Resolver being the first entry.

We could also add some kind of McpJsonUtilities.CreateOptions(MyContext.Default) helper that sets up the TypeInfoResolverChain correctly, but customers still need to know to do that.

A more involved option could be to emit a compiler diagnostic if we detect that any protocol types are either directly or transitively included in a JsonSerializerContext. The warning would suggest adding an [McpJsonContext] attribute to MyContext, and another source generator would detect that attribute and add a correctly-configured McpOptions property to MyContext. I haven't explored this though, so the implementation might be prohibitively complex for the benefit it brings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Experimental APIs force users to suppress diagnostics even when they are not used

5 participants

Comments