Skip to content

Enhance C# generator with richer typing and data annotations#1067

Open
stephentoub wants to merge 4 commits intomainfrom
stephentoub/enhance-type-generators-rich-typing
Open

Enhance C# generator with richer typing and data annotations#1067
stephentoub wants to merge 4 commits intomainfrom
stephentoub/enhance-type-generators-rich-typing

Conversation

@stephentoub
Copy link
Copy Markdown
Collaborator

The C# code generator (scripts/codegen/csharp.ts) emits types from JSON Schema but was leaving a lot of type information on the table -- using double for all numerics, string for dates and durations, no validation attributes, and concrete collection types in public APIs. This PR teaches the generator to produce idiomatic, well-annotated C# and applies the same patterns to the hand-written public API surface.

What changed

Richer type mappings in the generator:

  • integer -> long (JSON Schema integers can be up to 2^53, so int would be lossy)
  • number -> double (unchanged, but now distinct from integer)
  • format: "date-time" -> DateTimeOffset
  • format: "uuid" -> Guid
  • format: "duration" on numeric fields -> TimeSpan with a [JsonConverter] that converts milliseconds (the runtime's convention for duration fields differs from JSON Schema's ISO 8601 string definition -- see comment on isDurationProperty())

Data annotation attributes:

  • [Range] from minimum/maximum/exclusiveMinimum/exclusiveMaximum
  • [RegularExpression] from pattern
  • [Url] and [StringSyntax(StringSyntaxAttribute.Uri)] from format: "uri"
  • [StringSyntax(StringSyntaxAttribute.Regex)] from format: "regex"
  • [MinLength]/[MaxLength] from minLength/maxLength
  • [Base64String] from contentEncoding: "base64" or format: "byte" (no current schema fields use this yet, but the generator is wired up)

Collection interfaces in the public API:

  • All List<T> properties/returns/parameters -> IList<T>
  • All Dictionary<K,V> -> IDictionary<K,V>
  • Applied in both generated code and hand-written files (Types.cs, Client.cs, Session.cs)
  • Collection properties use lazy initialization (field ??= [] / field ??= new Dictionary<>()) instead of eager allocation

New file: dotnet/src/MillisecondsTimeSpanConverter.cs -- a standalone JsonConverter<TimeSpan> that serializes TimeSpan as milliseconds. Marked [EditorBrowsable(Never)] since it's an implementation detail.

Things to note

  • The session-events schema is relatively sparse -- it only has format: "date-time" and format: "uri", so SessionEvents.cs gets fewer annotations than Rpc.cs (which has ranges, patterns, durations, etc.).
  • Test changes are mechanical: explicit IList<T> types where Task<T> invariance requires it, new Dictionary<>() where [] doesn't work for IDictionary, and .FirstOrDefault() replacing List.Find().
  • The [DefaultValue] and [AllowedValues] attributes were investigated but not added -- the schemas have almost no default values and string enums are already mapped to C# enum types.

- integer -> long, number -> double (was double for both)
- format: date-time -> DateTimeOffset, uuid -> Guid, duration -> TimeSpan
- Add MillisecondsTimeSpanConverter for TimeSpan JSON serialization
- Emit [Range], [RegularExpression], [Url], [MinLength], [MaxLength]
- Emit [StringSyntax(Uri)], [StringSyntax(Regex)], [Base64String]
- Change all public collections from concrete to interface types
  (List<T> -> IList<T>, Dictionary<K,V> -> IDictionary<K,V>)
- Lazy-initialize collection properties via field ??= pattern

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@stephentoub stephentoub requested a review from a team as a code owner April 12, 2026 17:46
Copilot AI review requested due to automatic review settings April 12, 2026 17:46
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR upgrades the C# code generator and the .NET SDK surface to emit/consume richer types and more idiomatic collections, improving schema fidelity and public API flexibility across generated RPC/session-event models and hand-written types.

Changes:

  • Updated scripts/codegen/csharp.ts to map additional schema formats/types (e.g., integer→long, uri annotations) and to emit data-annotation attributes + duration handling hooks.
  • Switched many .NET public APIs/models from List<T>/Dictionary<K,V> to IList<T>/IDictionary<K,V> with lazy initialization patterns.
  • Added a TimeSpan JSON converter (milliseconds-based) and updated generated code + tests to match the new collection shapes.
Show a summary per file
File Description
scripts/codegen/csharp.ts Adds richer type mapping, annotation emission, and collection interface generation in the C# generator.
dotnet/src/MillisecondsTimeSpanConverter.cs Introduces a JsonConverter<TimeSpan> for millisecond-encoded durations.
dotnet/src/Types.cs Updates hand-written SDK types to use IList/IDictionary and lazy init; adjusts copy semantics.
dotnet/src/Client.cs Adjusts client API signatures to return/accept IList<T> and updates caching logic accordingly.
dotnet/src/Session.cs Updates internal request/response DTOs to use interface collections.
dotnet/src/Generated/SessionEvents.cs Regenerates session event models with updated integer typing and URI annotations.
dotnet/src/Generated/Rpc.cs Regenerates RPC models to use interface collections and updated numeric typing where applicable.
dotnet/test/SessionTests.cs Updates list search to LINQ (FirstOrDefault) to match interface collection return types.
dotnet/test/ElicitationTests.cs Uses concrete dictionary construction where interface-typed properties can’t use target-typed new().
dotnet/test/ClientTests.cs Updates explicit types to IList<T> where needed for invariance/compatibility.

Copilot's findings

Comments suppressed due to low confidence (1)

dotnet/src/Types.cs:1932

  • Same as SessionConfig: cloning McpServers via new Dictionary<string, McpServerConfig>(other.McpServers) drops any custom comparer on the original dictionary. If key comparison semantics matter (e.g., case-insensitive server names), preserve the comparer when possible (or explicitly define/normalize key casing).
        InfiniteSessions = other.InfiniteSessions;
        McpServers = other.McpServers is not null
            ? new Dictionary<string, McpServerConfig>(other.McpServers)
            : null;
  • Files reviewed: 9/11 changed files
  • Comments generated: 4

Comment on lines +223 to +233
// [Range]for minimum/maximum
const hasMin = typeof schema.minimum === "number";
const hasMax = typeof schema.maximum === "number";
if (hasMin || hasMax) {
const min = hasMin ? String(schema.minimum) : (schema.type === "integer" ? "long.MinValue" : "double.MinValue");
const max = hasMax ? String(schema.maximum) : (schema.type === "integer" ? "long.MaxValue" : "double.MaxValue");
const namedArgs: string[] = [];
if (schema.exclusiveMinimum === true) namedArgs.push("MinimumIsExclusive = true");
if (schema.exclusiveMaximum === true) namedArgs.push("MaximumIsExclusive = true");
const namedSuffix = namedArgs.length > 0 ? `, ${namedArgs.join(", ")}` : "";
attrs.push(`${indent}[Range(${min}, ${max}${namedSuffix})]`);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed -- now uses Range(typeof(long), "min", "max") overload for integer types, which properly handles long values.

Comment on lines +218 to +221
// [Base64String] for base64-encoded string properties
if (format === "byte" || (schema as Record<string, unknown>).contentEncoding === "base64") {
attrs.push(`${indent}[Base64String]`);
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Base64StringAttribute is a standard .NET 8+ attribute in System.ComponentModel.DataAnnotations (added alongside AllowedValues, DeniedValues, Length, etc.). This project targets net8.0 so it compiles fine. Currently no schema fields trigger it, but it will activate if the schema adds contentEncoding: "base64" or format: "byte" markup.


lines.push(...xmlDocPropertyComment(prop.description, propName, " "));
lines.push(...emitDataAnnotations(prop, " "));
if (isDurationProperty(prop)) lines.push(` [JsonConverter(typeof(MillisecondsTimeSpanConverter))]`);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed -- added using GitHub.Copilot.SDK; to the Rpc.cs header so the converter type resolves correctly when duration fields are present.

InfiniteSessions = other.InfiniteSessions;
McpServers = other.McpServers is not null
? new Dictionary<string, McpServerConfig>(other.McpServers, other.McpServers.Comparer)
? new Dictionary<string, McpServerConfig>(other.McpServers)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Already fixed in an earlier commit (56ba3cf). Both SessionConfig and ResumeSessionConfig clone constructors now check if the underlying IDictionary is a Dictionary<string, McpServerConfig> and preserve its Comparer when cloning.

@github-actions

This comment has been minimized.

Preserve dictionary comparer in SessionConfig/ResumeSessionConfig
Clone() by checking for Dictionary<> and passing its Comparer.
Fix byok.md to use Task.FromResult<IList<ModelInfo>>() for the
updated delegate signature.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Use Range(typeof(long), ...) overload since RangeAttribute has no
long constructor. Add 'using GitHub.Copilot.SDK' to Rpc.cs header
so MillisecondsTimeSpanConverter resolves when duration fields exist.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

Cross-SDK Consistency Review ✅

This PR is entirely focused on the .NET/C# SDK (codegen script + generated/hand-written files). Here's a quick cross-SDK check:

Change C# (this PR) Go Python TypeScript
integer → native int type long ✅ (was double, now fixed) int64 int via quicktype ✅ number (no distinction needed)
format: "date-time" DateTimeOffset time.Time datetime via quicktype ✅ string (quicktype default)
format: "uuid" Guid string str string
format: "duration" on numeric TimeSpan + ms converter float64 (no special casing) float (quicktype) number (quicktype)
Collection interfaces IList<T> / IDictionary<K,V> slices/maps (no distinction) list / dict T[] (interface by nature)
Data annotations [Range], [Url], [RegularExpression], etc. N/A (Go struct tags serve a different purpose) N/A N/A

Notable: The integer → long fix actually closes a gap where C# was behind the other SDKs (Go already uses int64). All other changes are C#-idiomatic improvements with no analogues needed in other languages.

No cross-SDK consistency concerns found.

Generated by SDK Consistency Review Agent for issue #1067 · ● 743.8K ·

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.

2 participants