Skip to content

Commit 5ade3c3

Browse files
Copilotstephentoub
andcommitted
Apply ASP.NET Core OpenAPI generator patterns: struct for caching, cancellation support, better string escaping, robust error handling
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 4f96860 commit 5ade3c3

2 files changed

Lines changed: 104 additions & 26 deletions

File tree

src/ModelContextProtocol.SourceGenerators/README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,18 @@ The generator intelligently fills in missing Description attributes:
8181

8282
The generator only generates attributes that are missing, so you can mix XML documentation with explicit Description attributes as needed.
8383

84+
### Implementation Details
85+
86+
- **Incremental Generator**: Uses `IIncrementalGenerator` for optimal build performance
87+
- **Symbol-based Detection**: Compares attribute symbols using `SymbolEqualityComparer` for reliability
88+
- **Efficient Filtering**: Syntax-only predicate filters candidates before expensive semantic analysis
89+
- **Cancellation Support**: Respects cancellation tokens throughout generation pipeline
90+
- **String Escaping**: Properly escapes special characters (quotes, backslashes, newlines, tabs)
91+
- **Error Handling**: Gracefully handles invalid XML in documentation comments
92+
8493
### Notes
8594

8695
- Multi-line XML comments are combined into single-line descriptions
8796
- Only partial methods with implementations (method bodies) are supported
88-
- The generator uses symbol comparison for efficient attribute detection
89-
- Implemented as an incremental generator for optimal performance
97+
- Generic types are handled with proper arity in generated file names
98+
- All generated code is marked with `<auto-generated/>` and `#nullable enable`

src/ModelContextProtocol.SourceGenerators/XmlToDescriptionGenerator.cs

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Collections.Immutable;
77
using System.Linq;
88
using System.Text;
9+
using System.Threading;
910
using System.Xml.Linq;
1011

1112
namespace ModelContextProtocol.SourceGenerators;
@@ -19,34 +20,54 @@ public class XmlToDescriptionGenerator : IIncrementalGenerator
1920
{
2021
public void Initialize(IncrementalGeneratorInitializationContext context)
2122
{
22-
// Filter method declarations with attributes
23-
var methodDeclarations = context.SyntaxProvider
23+
// Filter method declarations with attributes and transform to model
24+
var methodModels = context.SyntaxProvider
2425
.CreateSyntaxProvider(
25-
predicate: static (s, _) => s is MethodDeclarationSyntax { AttributeLists.Count: > 0 },
26-
transform: static (ctx, _) => GetMethodForGeneration(ctx))
26+
predicate: static (s, _) => IsCandidateMethod(s),
27+
transform: static (ctx, ct) => GetMethodModel(ctx, ct))
2728
.Where(static m => m is not null);
2829

29-
// Combine with compilation to get symbols
30-
var compilationAndMethods = context.CompilationProvider.Combine(methodDeclarations.Collect());
30+
// Combine with compilation to get well-known type symbols
31+
var compilationAndMethods = context.CompilationProvider.Combine(methodModels.Collect());
3132

3233
context.RegisterSourceOutput(compilationAndMethods,
3334
static (spc, source) => Execute(source.Left, source.Right!, spc));
3435
}
3536

36-
private static MethodDeclarationSyntax? GetMethodForGeneration(GeneratorSyntaxContext context)
37+
private static bool IsCandidateMethod(SyntaxNode node)
38+
{
39+
// Quick syntax-only filter
40+
return node is MethodDeclarationSyntax
41+
{
42+
AttributeLists.Count: > 0
43+
} method && method.Modifiers.Any(SyntaxKind.PartialKeyword);
44+
}
45+
46+
private static MethodToGenerate? GetMethodModel(GeneratorSyntaxContext context, CancellationToken cancellationToken)
3747
{
3848
var methodDeclaration = (MethodDeclarationSyntax)context.Node;
49+
50+
cancellationToken.ThrowIfCancellationRequested();
51+
52+
var methodSymbol = context.SemanticModel.GetDeclaredSymbol(methodDeclaration, cancellationToken);
53+
if (methodSymbol == null)
54+
{
55+
return null;
56+
}
3957

40-
// Quick check: must be partial
41-
if (!methodDeclaration.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword)))
58+
// Check for McpServerTool attribute early (before expensive XML parsing)
59+
var hasMcpAttribute = methodSymbol.GetAttributes().Any(attr =>
60+
attr.AttributeClass?.Name is "McpServerToolAttribute" or "McpServerTool");
61+
62+
if (!hasMcpAttribute)
4263
{
4364
return null;
4465
}
4566

46-
return methodDeclaration;
67+
return new MethodToGenerate(methodDeclaration, methodSymbol);
4768
}
4869

49-
private static void Execute(Compilation compilation, ImmutableArray<MethodDeclarationSyntax> methods, SourceProductionContext context)
70+
private static void Execute(Compilation compilation, ImmutableArray<MethodToGenerate?> methods, SourceProductionContext context)
5071
{
5172
if (methods.IsDefaultOrEmpty)
5273
{
@@ -57,23 +78,26 @@ private static void Execute(Compilation compilation, ImmutableArray<MethodDeclar
5778
var mcpServerToolAttribute = compilation.GetTypeByMetadataName("ModelContextProtocol.Server.McpServerToolAttribute");
5879
var descriptionAttribute = compilation.GetTypeByMetadataName("System.ComponentModel.DescriptionAttribute");
5980

60-
if (mcpServerToolAttribute == null || descriptionAttribute == null)
81+
if (descriptionAttribute == null)
6182
{
83+
// Description attribute is required - can't generate without it
6284
return;
6385
}
6486

65-
foreach (var methodDeclaration in methods)
87+
foreach (var methodModel in methods)
6688
{
67-
var semanticModel = compilation.GetSemanticModel(methodDeclaration.SyntaxTree);
68-
var methodSymbol = semanticModel.GetDeclaredSymbol(methodDeclaration);
69-
70-
if (methodSymbol == null)
89+
if (methodModel == null)
7190
{
7291
continue;
7392
}
7493

75-
// Check if method has McpServerTool attribute
76-
if (!HasAttribute(methodSymbol, mcpServerToolAttribute))
94+
context.CancellationToken.ThrowIfCancellationRequested();
95+
96+
var methodSymbol = methodModel.Value.MethodSymbol;
97+
var methodDeclaration = methodModel.Value.MethodDeclaration;
98+
99+
// Double-check McpServerTool attribute with symbol comparison if available
100+
if (mcpServerToolAttribute != null && !HasAttribute(methodSymbol, mcpServerToolAttribute))
77101
{
78102
continue;
79103
}
@@ -89,8 +113,8 @@ private static void Execute(Compilation compilation, ImmutableArray<MethodDeclar
89113
var source = GeneratePartialMethodDeclaration(methodSymbol, methodDeclaration, xmlDocs, descriptionAttribute);
90114
if (source != null)
91115
{
92-
var fileName = $"{methodSymbol.ContainingType.Name}_{methodSymbol.Name}_Description.g.cs";
93-
context.AddSource(fileName, SourceText.From(source, Encoding.UTF8));
116+
var hintName = GetHintName(methodSymbol);
117+
context.AddSource(hintName, SourceText.From(source, Encoding.UTF8));
94118
}
95119
}
96120
}
@@ -134,7 +158,7 @@ private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol attributeType)
134158
return null;
135159
}
136160

137-
var paramDocs = new Dictionary<string, string>();
161+
var paramDocs = new Dictionary<string, string>(StringComparer.Ordinal);
138162
foreach (var paramElement in memberElement.Elements("param"))
139163
{
140164
var name = paramElement.Attribute("name")?.Value;
@@ -152,8 +176,9 @@ private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol attributeType)
152176
Parameters = paramDocs
153177
};
154178
}
155-
catch
179+
catch (System.Xml.XmlException)
156180
{
181+
// Invalid XML in documentation comments - skip this method
157182
return null;
158183
}
159184
}
@@ -308,9 +333,53 @@ private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol typ
308333

309334
private static string EscapeString(string text)
310335
{
311-
return text.Replace("\\", "\\\\").Replace("\"", "\\\"");
336+
if (string.IsNullOrEmpty(text))
337+
{
338+
return text;
339+
}
340+
341+
// Escape special characters for C# string literals
342+
return text
343+
.Replace("\\", "\\\\") // Backslash must be first
344+
.Replace("\"", "\\\"") // Quote
345+
.Replace("\r", "\\r") // Carriage return
346+
.Replace("\n", "\\n") // Newline
347+
.Replace("\t", "\\t"); // Tab
348+
}
349+
350+
private static string GetHintName(IMethodSymbol methodSymbol)
351+
{
352+
var containingType = methodSymbol.ContainingType;
353+
var typeName = containingType.Name;
354+
355+
// Include generic arity if present to avoid collisions
356+
if (containingType.IsGenericType)
357+
{
358+
typeName = $"{typeName}`{containingType.Arity}";
359+
}
360+
361+
return $"{typeName}.{methodSymbol.Name}.g.cs";
362+
}
363+
364+
/// <summary>
365+
/// Represents a method that may need Description attributes generated.
366+
/// Using a struct for better incremental generator caching.
367+
/// </summary>
368+
private readonly struct MethodToGenerate
369+
{
370+
public MethodToGenerate(MethodDeclarationSyntax methodDeclaration, IMethodSymbol methodSymbol)
371+
{
372+
MethodDeclaration = methodDeclaration;
373+
MethodSymbol = methodSymbol;
374+
}
375+
376+
public MethodDeclarationSyntax MethodDeclaration { get; }
377+
public IMethodSymbol MethodSymbol { get; }
312378
}
313379

380+
/// <summary>
381+
/// Holds extracted XML documentation for a method.
382+
/// </summary>
314383
private sealed class XmlDocumentation
315384
{
316385
public string MethodDescription { get; set; } = string.Empty;

0 commit comments

Comments
 (0)