From 32619c0844c4e1a11199f6eb25b003bee022f818 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 30 Oct 2025 14:32:36 -0400 Subject: [PATCH 1/2] Add a source generator for creating [Description]s from XML comments on tools/prompts/resources --- Directory.Packages.props | 11 +- ModelContextProtocol.slnx | 2 + README.md | 5 + .../Diagnostics.cs | 22 + .../ModelContextProtocol.Analyzers.csproj | 20 + .../XmlToDescriptionGenerator.cs | 386 +++++++++ .../ModelContextProtocol.Core.csproj | 17 + ...odelContextProtocol.Analyzers.Tests.csproj | 36 + .../XmlToDescriptionGeneratorTests.cs | 752 ++++++++++++++++++ 9 files changed, 1249 insertions(+), 2 deletions(-) create mode 100644 src/ModelContextProtocol.Analyzers/Diagnostics.cs create mode 100644 src/ModelContextProtocol.Analyzers/ModelContextProtocol.Analyzers.csproj create mode 100644 src/ModelContextProtocol.Analyzers/XmlToDescriptionGenerator.cs create mode 100644 tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj create mode 100644 tests/ModelContextProtocol.Analyzers.Tests/XmlToDescriptionGeneratorTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 656f5fe9c..f86a7c695 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,12 +48,19 @@ + + + + - + + + - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index a70e3e310..1f6dce1ed 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -62,11 +62,13 @@ + + diff --git a/README.md b/README.md index 3099dfcd3..19672bfe5 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,11 @@ await using McpServer server = McpServer.Create(new StdioServerTransport("MyServ await server.RunAsync(); ``` +Descriptions can be added to tools, prompts, and resources in a variety of ways, including via the `[Description]` attribute from `System.ComponentModel`. +This attribute may be placed on a method to provide for the tool, prompt, or resource, or on individual parameters to describe each's purpose. +XML comments may also be used; if an `[McpServerTool]`, `[McpServerPrompt]`, or `[McpServerResource]`-attributed method is marked as `partial`, +XML comments placed on the method will be used automatically to generate `[Description]` attributes for the method and its parameters. + ## Acknowledgements The starting point for this library was a project called [mcpdotnet](https://github.com/PederHP/mcpdotnet), initiated by [Peder Holdgaard Pedersen](https://github.com/PederHP). We are grateful for the work done by Peder and other contributors to that repository, which created a solid foundation for this library. diff --git a/src/ModelContextProtocol.Analyzers/Diagnostics.cs b/src/ModelContextProtocol.Analyzers/Diagnostics.cs new file mode 100644 index 000000000..3dd486be0 --- /dev/null +++ b/src/ModelContextProtocol.Analyzers/Diagnostics.cs @@ -0,0 +1,22 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Immutable; +using System.Text; +using System.Xml.Linq; + +namespace ModelContextProtocol.Analyzers; + +/// Provides the diagnostic descriptors used by the assembly. +internal static class Diagnostics +{ + public static DiagnosticDescriptor InvalidXmlDocumentation { get; } = new( + id: "MCP001", + title: "Invalid XML documentation for MCP method", + messageFormat: "XML comment for method '{0}' is invalid and cannot be processed to generate [Description] attributes.", + category: "mcp", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The XML documentation comment contains invalid XML and cannot be processed to generate Description attributes."); +} diff --git a/src/ModelContextProtocol.Analyzers/ModelContextProtocol.Analyzers.csproj b/src/ModelContextProtocol.Analyzers/ModelContextProtocol.Analyzers.csproj new file mode 100644 index 000000000..5338bbb84 --- /dev/null +++ b/src/ModelContextProtocol.Analyzers/ModelContextProtocol.Analyzers.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + true + false + true + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Analyzers/XmlToDescriptionGenerator.cs b/src/ModelContextProtocol.Analyzers/XmlToDescriptionGenerator.cs new file mode 100644 index 000000000..6d134fcc8 --- /dev/null +++ b/src/ModelContextProtocol.Analyzers/XmlToDescriptionGenerator.cs @@ -0,0 +1,386 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System.Collections.Immutable; +using System.Text; +using System.Xml.Linq; + +namespace ModelContextProtocol.Analyzers; + +/// +/// Source generator that creates [Description] attributes from XML comments +/// for partial methods tagged with MCP attributes. +/// +[Generator] +public sealed class XmlToDescriptionGenerator : IIncrementalGenerator +{ + private const string GeneratedFileName = "ModelContextProtocol.Descriptions.g.cs"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Filter method declarations with attributes. We're looking for attributed partial methods. + var methodModels = context.SyntaxProvider + .CreateSyntaxProvider( + static (s, _) => s is MethodDeclarationSyntax { AttributeLists.Count: > 0 } method && method.Modifiers.Any(SyntaxKind.PartialKeyword), + static (ctx, ct) => + { + var methodDeclaration = (MethodDeclarationSyntax)ctx.Node; + return ctx.SemanticModel.GetDeclaredSymbol(methodDeclaration, ct) is { } methodSymbol ? + new MethodToGenerate(methodDeclaration, methodSymbol) : + null; + }) + .Where(static m => m is not null); + + // Combine with compilation to get well-known type symbols. + var compilationAndMethods = context.CompilationProvider.Combine(methodModels.Collect()); + + // Write out the source for all methods. + context.RegisterSourceOutput(compilationAndMethods, static (spc, source) => Execute(source.Left, source.Right!, spc)); + } + + private static void Execute(Compilation compilation, ImmutableArray methods, SourceProductionContext context) + { + if (methods.IsDefaultOrEmpty) + { + return; + } + + // Get well-known type symbols upfront. If any of them are missing, give up. + var toolAttribute = compilation.GetTypeByMetadataName("ModelContextProtocol.Server.McpServerToolAttribute"); + var promptAttribute = compilation.GetTypeByMetadataName("ModelContextProtocol.Server.McpServerPromptAttribute"); + var resourceAttribute = compilation.GetTypeByMetadataName("ModelContextProtocol.Server.McpServerResourceAttribute"); + var descriptionAttribute = compilation.GetTypeByMetadataName("System.ComponentModel.DescriptionAttribute"); + if (descriptionAttribute is null || toolAttribute is null || promptAttribute is null || resourceAttribute is null) + { + return; + } + + // Gather a list of all methods needing generation. + List<(IMethodSymbol MethodSymbol, MethodDeclarationSyntax MethodDeclaration, XmlDocumentation? XmlDocs)>? methodsToGenerate = null; + foreach (var methodModel in methods) + { + if (methodModel is not null) + { + // Check if method has any MCP attribute with symbol comparison + var methodSymbol = methodModel.Value.MethodSymbol; + bool hasMcpAttribute = + HasAttribute(methodSymbol, toolAttribute) || + HasAttribute(methodSymbol, promptAttribute) || + HasAttribute(methodSymbol, resourceAttribute); + if (hasMcpAttribute) + { + // Extract XML documentation. Even if there's no documentation or it's invalid, + // we still need to generate the partial implementation to avoid compilation errors. + var xmlDocs = ExtractXmlDocumentation(methodSymbol, context); + + // Always add the method to generate its implementation, but emit diagnostics + // to guide the developer if documentation is missing or invalid. + (methodsToGenerate ??= []).Add((methodSymbol, methodModel.Value.MethodDeclaration, xmlDocs)); + } + } + } + + // Generate a single file with all partial declarations. + if (methodsToGenerate is not null) + { + string source = GenerateSourceFile(compilation, methodsToGenerate, descriptionAttribute); + context.AddSource(GeneratedFileName, SourceText.From(source, Encoding.UTF8)); + } + } + + private static XmlDocumentation? ExtractXmlDocumentation(IMethodSymbol methodSymbol, SourceProductionContext context) + { + string? xmlDoc = methodSymbol.GetDocumentationCommentXml(); + if (string.IsNullOrWhiteSpace(xmlDoc)) + { + return null; + } + + try + { + if (XDocument.Parse(xmlDoc).Element("member") is not { } memberElement) + { + return null; + } + + var summary = CleanXmlDocText(memberElement.Element("summary")?.Value); + var remarks = CleanXmlDocText(memberElement.Element("remarks")?.Value); + var returns = CleanXmlDocText(memberElement.Element("returns")?.Value); + + // Combine summary and remarks for method description. + var methodDescription = + string.IsNullOrWhiteSpace(remarks) ? summary : + string.IsNullOrWhiteSpace(summary) ? remarks : + $"{summary}\n{remarks}"; + + Dictionary paramDocs = new(StringComparer.Ordinal); + foreach (var paramElement in memberElement.Elements("param")) + { + var name = paramElement.Attribute("name")?.Value; + var value = CleanXmlDocText(paramElement.Value); + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(value)) + { + paramDocs[name!] = value; + } + } + + // Return documentation even if empty - we'll still generate the partial implementation + return new XmlDocumentation(methodDescription ?? string.Empty, returns ?? string.Empty, paramDocs); + } + catch (System.Xml.XmlException) + { + // Emit warning for invalid XML + context.ReportDiagnostic(Diagnostic.Create( + Diagnostics.InvalidXmlDocumentation, + methodSymbol.Locations.FirstOrDefault(), + methodSymbol.Name)); + return null; + } + } + + private static string CleanXmlDocText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + // Remove leading/trailing whitespace and normalize line breaks + var lines = text!.Split('\n') + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrEmpty(line)); + + return string.Join(" ", lines).Trim(); + } + + private static string GenerateSourceFile( + Compilation compilation, + List<(IMethodSymbol MethodSymbol, MethodDeclarationSyntax MethodDeclaration, XmlDocumentation? XmlDocs)> methods, + INamedTypeSymbol descriptionAttribute) + { + var sb = new StringBuilder(); + sb.AppendLine("// ") + .AppendLine($"// ModelContextProtocol.Analyzers {typeof(XmlToDescriptionGenerator).Assembly.GetName().Version}") + .AppendLine() + .AppendLine("#pragma warning disable") + .AppendLine() + .AppendLine("using System.ComponentModel;") + .AppendLine("using ModelContextProtocol.Server;") + .AppendLine(); + + // Group methods by namespace and containing type + var groupedMethods = methods.GroupBy(m => + m.MethodSymbol.ContainingNamespace.Name == compilation.GlobalNamespace.Name ? "" : + m.MethodSymbol.ContainingNamespace?.ToDisplayString() ?? + ""); + + foreach (var namespaceGroup in groupedMethods) + { + // Check if this is the global namespace (methods with null ContainingNamespace) + bool isGlobalNamespace = string.IsNullOrEmpty(namespaceGroup.Key); + if (!isGlobalNamespace) + { + sb.Append("namespace ") + .AppendLine(namespaceGroup.Key) + .AppendLine("{"); + } + + // Group by containing type within namespace + var typeGroups = namespaceGroup.GroupBy(m => m.MethodSymbol.ContainingType, SymbolEqualityComparer.Default); + + foreach (var typeGroup in typeGroups) + { + if (typeGroup.Key is not INamedTypeSymbol containingType) + { + continue; + } + + // Calculate nesting depth for proper indentation + // For global namespace, start at 0; for namespaced types, start at 1 + int nestingDepth = isGlobalNamespace ? 0 : 1; + var temp = containingType; + while (temp is not null) + { + nestingDepth++; + temp = temp.ContainingType; + } + + // Handle nested types by building the full type hierarchy + int startIndent = isGlobalNamespace ? 0 : 1; + AppendNestedTypeDeclarations(sb, containingType, startIndent, typeGroup, descriptionAttribute, nestingDepth); + + sb.AppendLine(); + } + + if (!isGlobalNamespace) + { + sb.AppendLine("}"); + } + } + + return sb.ToString(); + } + + private static void AppendNestedTypeDeclarations( + StringBuilder sb, + INamedTypeSymbol typeSymbol, + int indentLevel, + IGrouping typeGroup, + INamedTypeSymbol descriptionAttribute, + int nestingDepth) + { + // Build stack of nested types from innermost to outermost + Stack types = new(); + for (var current = typeSymbol; current is not null; current = current.ContainingType) + { + types.Push(current); + } + + // Generate type declarations from outermost to innermost + int nestingCount = types.Count; + while (types.Count > 0) + { + // Get the type keyword and handle records + var type = types.Pop(); + var typeDecl = type.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() as TypeDeclarationSyntax; + string typeKeyword; + if (typeDecl is RecordDeclarationSyntax rds) + { + var classOrStruct = rds.ClassOrStructKeyword.ValueText; + typeKeyword = string.IsNullOrEmpty(classOrStruct) ? + $"{typeDecl.Keyword.ValueText} class" : + $"{typeDecl.Keyword.ValueText} {classOrStruct}"; + } + else + { + typeKeyword = typeDecl?.Keyword.ValueText ?? "class"; + } + + sb.Append(' ', indentLevel * 4).Append("partial ").Append(typeKeyword).Append(' ').AppendLine(type.Name) + .Append(' ', indentLevel * 4).AppendLine("{"); + + indentLevel++; + } + + // Generate methods for this type. + bool firstMethodInType = true; + foreach (var (methodSymbol, methodDeclaration, xmlDocs) in typeGroup) + { + AppendMethodDeclaration(sb, methodSymbol, methodDeclaration, xmlDocs, descriptionAttribute, firstMethodInType, nestingDepth); + firstMethodInType = false; + } + + // Close all type declarations. + for (int i = 0; i < nestingCount; i++) + { + indentLevel--; + sb.Append(' ', indentLevel * 4).AppendLine("}"); + } + } + + private static void AppendMethodDeclaration( + StringBuilder sb, + IMethodSymbol methodSymbol, + MethodDeclarationSyntax methodDeclaration, + XmlDocumentation? xmlDocs, + INamedTypeSymbol descriptionAttribute, + bool firstMethodInType, + int indentLevel) + { + int indent = indentLevel * 4; + + if (!firstMethodInType) + { + sb.AppendLine(); + } + + // Add the Description attribute for method if needed and documentation exists + if (xmlDocs is not null && + !string.IsNullOrWhiteSpace(xmlDocs.MethodDescription) && + !HasAttribute(methodSymbol, descriptionAttribute)) + { + sb.Append(' ', indent) + .Append("[Description(\"") + .Append(EscapeString(xmlDocs.MethodDescription)) + .AppendLine("\")]"); + } + + // Add return: Description attribute if needed and documentation exists + if (xmlDocs is not null && + !string.IsNullOrWhiteSpace(xmlDocs.Returns) && + methodSymbol.GetReturnTypeAttributes().All(attr => !SymbolEqualityComparer.Default.Equals(attr.AttributeClass, descriptionAttribute))) + { + sb.Append(' ', indent) + .Append("[return: Description(\"") + .Append(EscapeString(xmlDocs.Returns)) + .AppendLine("\")]"); + } + + // Copy modifiers from original method syntax. + // Add return type (without nullable annotations). + // Add method name. + sb.Append(' ', indent) + .Append(string.Join(" ", methodDeclaration.Modifiers.Select(m => m.Text))) + .Append(' ') + .Append(methodSymbol.ReturnType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) + .Append(' ') + .Append(methodSymbol.Name); + + // Add parameters with their Description attributes. + sb.Append("("); + for (int i = 0; i < methodSymbol.Parameters.Length; i++) + { + IParameterSymbol param = methodSymbol.Parameters[i]; + + if (i > 0) + { + sb.Append(", "); + } + + if (xmlDocs is not null && + !HasAttribute(param, descriptionAttribute) && + xmlDocs.Parameters.TryGetValue(param.Name, out var paramDoc) && + !string.IsNullOrWhiteSpace(paramDoc)) + { + sb.Append("[Description(\"") + .Append(EscapeString(paramDoc)) + .Append("\")] "); + } + + sb.Append(param.Type.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) + .Append(' ') + .Append(param.Name); + } + sb.AppendLine(");"); + } + + /// Checks if a symbol has a specific attribute applied. + private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol attributeType) + { + foreach (var attr in symbol.GetAttributes()) + { + if (SymbolEqualityComparer.Default.Equals(attr.AttributeClass, attributeType)) + { + return true; + } + } + + return false; + } + + /// Escape special characters for C# string literals. + private static string EscapeString(string text) => + string.IsNullOrEmpty(text) ? text : + text.Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t"); + + /// Represents a method that may need Description attributes generated. + private readonly record struct MethodToGenerate(MethodDeclarationSyntax MethodDeclaration, IMethodSymbol MethodSymbol); + + /// Holds extracted XML documentation for a method. + private sealed record XmlDocumentation(string MethodDescription, string Returns, Dictionary Parameters); +} diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj index d39c008eb..cdbe25a2d 100644 --- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj +++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj @@ -43,6 +43,23 @@ + + + + + + + + + + + diff --git a/tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj b/tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj new file mode 100644 index 000000000..430db1b02 --- /dev/null +++ b/tests/ModelContextProtocol.Analyzers.Tests/ModelContextProtocol.Analyzers.Tests.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + enable + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + diff --git a/tests/ModelContextProtocol.Analyzers.Tests/XmlToDescriptionGeneratorTests.cs b/tests/ModelContextProtocol.Analyzers.Tests/XmlToDescriptionGeneratorTests.cs new file mode 100644 index 000000000..8c1fb8bf7 --- /dev/null +++ b/tests/ModelContextProtocol.Analyzers.Tests/XmlToDescriptionGeneratorTests.cs @@ -0,0 +1,752 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using System.Diagnostics.CodeAnalysis; +using Xunit; + +namespace ModelContextProtocol.Analyzers.Tests; + +public partial class XmlToDescriptionGeneratorTests +{ + [Fact] + public void Generator_WithSummaryOnly_GeneratesMethodDescription() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Test tool description + /// + [McpServerTool] + public static partial string TestMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("[Description(\"Test tool description\")]", generatedSource); + Assert.Contains("public static partial string TestMethod", generatedSource); + } + + [Fact] + public void Generator_WithSummaryAndRemarks_CombinesInMethodDescription() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Test tool summary + /// + /// + /// Additional remarks + /// + [McpServerTool] + public static partial string TestMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("[Description(\"Test tool summary\\nAdditional remarks\")]", generatedSource); + } + + [Fact] + public void Generator_WithParameterDocs_GeneratesParameterDescriptions() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Test tool + /// + /// Input parameter description + /// Count parameter description + [McpServerTool] + public static partial string TestMethod(string input, int count) + { + return input; + } + } + """); + + Assert.True(result.Success); + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("[Description(\"Input parameter description\")]", generatedSource); + Assert.Contains("[Description(\"Count parameter description\")]", generatedSource); + } + + [Fact] + public void Generator_WithReturnDocs_GeneratesReturnDescription() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Test tool + /// + /// The result of the operation + [McpServerTool] + public static partial string TestMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("[return: Description(\"The result of the operation\")]", generatedSource); + } + + [Fact] + public void Generator_WithExistingMethodDescription_DoesNotGenerateMethodDescription() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Test tool summary + /// + /// Result + [McpServerTool] + [Description("Already has description")] + public static partial string TestMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + // Should not contain method description, only return description + Assert.DoesNotContain("Test tool summary", generatedSource); + Assert.Contains("[return: Description(\"Result\")]", generatedSource); + } + + [Fact] + public void Generator_WithExistingParameterDescription_SkipsThatParameter() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Test tool + /// + /// Input description + /// Count description + [McpServerTool] + public static partial string TestMethod(string input, [Description("Already has")] int count) + { + return input; + } + } + """); + + Assert.True(result.Success); + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + // Should generate description for input but not count + Assert.Contains("[Description(\"Input description\")] string input", generatedSource); + Assert.DoesNotContain("Count description", generatedSource); + } + + [Fact] + public void Generator_WithoutMcpServerToolAttribute_DoesNotGenerate() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + public partial class TestTools + { + /// + /// Test tool + /// + public static partial string TestMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Empty(result.GeneratedSources); + } + + [Fact] + public void Generator_WithoutPartialKeyword_DoesNotGenerate() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public class TestTools + { + /// + /// Test tool + /// + [McpServerTool] + public static string TestMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Empty(result.GeneratedSources); + } + + [Fact] + public void Generator_WithSpecialCharacters_EscapesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Test with "quotes", \backslash, newline + /// and tab characters. + /// + /// Parameter with "quotes" + [McpServerTool] + public static partial string TestEscaping(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + // Verify quotes are escaped + Assert.Contains("\\\"quotes\\\"", generatedSource); + // Verify backslashes are escaped + Assert.Contains("\\\\backslash", generatedSource); + } + + [Fact] + public void Generator_WithInvalidXml_GeneratesPartialAndReportsDiagnostic() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Test with + [McpServerTool] + public static partial string TestInvalidXml(string input) + { + return input; + } + } + """); + + // Should not throw, generates partial implementation without Description attributes + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + // Should generate the partial implementation + Assert.Contains("public static partial string TestInvalidXml", generatedSource); + // Should NOT contain Description attribute since XML was invalid + Assert.DoesNotContain("[Description(", generatedSource); + + // Should report a warning diagnostic + var diagnostic = Assert.Single(result.Diagnostics, d => d.Id == "MCP001"); + Assert.Equal(DiagnosticSeverity.Warning, diagnostic.Severity); + Assert.Contains("invalid", diagnostic.GetMessage(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generator_WithGenericType_GeneratesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Test generic + /// + [McpServerTool] + public static partial string TestGeneric(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("[Description(\"Test generic\")]", generatedSource); + } + + [Fact] + public void Generator_WithEmptyXmlComments_GeneratesPartialWithoutDescription() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// + [McpServerTool] + public static partial string TestEmpty(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + // Should generate the partial implementation + Assert.Contains("public static partial string TestEmpty", generatedSource); + // Should NOT contain Description attribute since documentation was empty + Assert.DoesNotContain("[Description(", generatedSource); + } + + [Fact] + public void Generator_WithMultilineComments_CombinesIntoSingleLine() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// First line + /// Second line + /// Third line + /// + [McpServerTool] + public static partial string TestMultiline(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("[Description(\"First line Second line Third line\")]", generatedSource); + } + + [Fact] + public void Generator_WithParametersOnly_GeneratesParameterDescriptions() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// Input parameter + /// Count parameter + [McpServerTool] + public static partial string TestMethod(string input, int count) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("[Description(\"Input parameter\")]", generatedSource); + Assert.Contains("[Description(\"Count parameter\")]", generatedSource); + } + + [Fact] + public void Generator_WithNestedType_GeneratesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + public partial class OuterClass + { + [McpServerToolType] + public partial class InnerClass + { + /// + /// Nested tool + /// + [McpServerTool] + public static partial string NestedMethod(string input) + { + return input; + } + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("partial class OuterClass", generatedSource); + Assert.Contains("partial class InnerClass", generatedSource); + Assert.Contains("[Description(\"Nested tool\")]", generatedSource); + } + + [Fact] + public void Generator_WithRecordClass_GeneratesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial record TestTools + { + /// + /// Record tool + /// + [McpServerTool] + public static partial string RecordMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + // Records are generated with "record class" keyword + Assert.Contains("partial record class TestTools", generatedSource); + Assert.Contains("[Description(\"Record tool\")]", generatedSource); + } + + [Fact] + public void Generator_WithRecordStruct_GeneratesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial record struct TestTools + { + /// + /// Record struct tool + /// + [McpServerTool] + public static partial string RecordStructMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("partial record struct TestTools", generatedSource); + Assert.Contains("[Description(\"Record struct tool\")]", generatedSource); + } + + [Fact] + public void Generator_WithVirtualMethod_GeneratesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public partial class TestTools + { + /// + /// Virtual tool + /// + [McpServerTool] + public virtual partial string VirtualMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("public virtual partial string VirtualMethod", generatedSource); + } + + [Fact] + public void Generator_WithAbstractMethod_GeneratesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerToolType] + public abstract partial class TestTools + { + /// + /// Abstract tool + /// + [McpServerTool] + public abstract partial string AbstractMethod(string input); + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("public abstract partial string AbstractMethod", generatedSource); + } + + [Fact] + public void Generator_WithMcpServerPrompt_GeneratesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerPromptType] + public partial class TestPrompts + { + /// + /// Test prompt + /// + [McpServerPrompt] + public static partial string TestPrompt(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("[Description(\"Test prompt\")]", generatedSource); + } + + [Fact] + public void Generator_WithMcpServerResource_GeneratesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + namespace Test; + + [McpServerResourceType] + public partial class TestResources + { + /// + /// Test resource + /// + [McpServerResource("test://resource")] + public static partial string TestResource(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + Assert.Contains("[Description(\"Test resource\")]", generatedSource); + } + + private GeneratorRunResult RunGenerator([StringSyntax("C#-test")] string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + + // Get reference assemblies - we need to include all the basic runtime types + List referenceList = + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.ComponentModel.DescriptionAttribute).Assembly.Location), + ]; + + // Add all necessary runtime assemblies + var runtimePath = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + referenceList.Add(MetadataReference.CreateFromFile(Path.Combine(runtimePath, "System.Runtime.dll"))); + referenceList.Add(MetadataReference.CreateFromFile(Path.Combine(runtimePath, "netstandard.dll"))); + + // Try to find and add ModelContextProtocol.Core + try + { + var coreAssemblyPath = Path.Combine(AppContext.BaseDirectory, "ModelContextProtocol.Core.dll"); + if (File.Exists(coreAssemblyPath)) + { + referenceList.Add(MetadataReference.CreateFromFile(coreAssemblyPath)); + } + } + catch + { + // If we can't find it, the compilation will fail with appropriate errors + } + + var compilation = CSharpCompilation.Create( + "TestAssembly", + [syntaxTree], + referenceList, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = (CSharpGeneratorDriver)CSharpGeneratorDriver + .Create(new XmlToDescriptionGenerator()) + .RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + var runResult = driver.GetRunResult(); + + return new GeneratorRunResult + { + Success = !diagnostics.Any(d => d.Severity == DiagnosticSeverity.Error), + GeneratedSources = runResult.GeneratedTrees.Select(t => (t.FilePath, t.GetText())).ToList(), + Diagnostics = diagnostics.ToList(), + Compilation = outputCompilation + }; + } + + [Fact] + public void Generator_WithGlobalNamespace_GeneratesCorrectly() + { + var result = RunGenerator(""" + using ModelContextProtocol.Server; + using System.ComponentModel; + + [McpServerToolType] + public partial class GlobalTools + { + /// + /// Tool in global namespace + /// + [McpServerTool] + public static partial string GlobalMethod(string input) + { + return input; + } + } + """); + + Assert.True(result.Success); + Assert.Single(result.GeneratedSources); + + var generatedSource = result.GeneratedSources[0].SourceText.ToString(); + + // Should not have a namespace declaration + Assert.DoesNotContain("namespace ", generatedSource); + // Should have the class at the root level + Assert.Contains("partial class GlobalTools", generatedSource); + Assert.Contains("[Description(\"Tool in global namespace\")]", generatedSource); + } + + private class GeneratorRunResult + { + public bool Success { get; set; } + public List<(string FilePath, Microsoft.CodeAnalysis.Text.SourceText SourceText)> GeneratedSources { get; set; } = []; + public List Diagnostics { get; set; } = []; + public Compilation? Compilation { get; set; } + } +} From cb5ed32f19465e552d5b03b7f20bdcddb7ffcd3f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 31 Oct 2025 05:32:44 +0000 Subject: [PATCH 2/2] Remove explicit generic type parameter from CreateSyntaxProvider - can be inferred Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com> --- src/ModelContextProtocol.Analyzers/XmlToDescriptionGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Analyzers/XmlToDescriptionGenerator.cs b/src/ModelContextProtocol.Analyzers/XmlToDescriptionGenerator.cs index 6d134fcc8..ae0808bd4 100644 --- a/src/ModelContextProtocol.Analyzers/XmlToDescriptionGenerator.cs +++ b/src/ModelContextProtocol.Analyzers/XmlToDescriptionGenerator.cs @@ -21,7 +21,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) { // Filter method declarations with attributes. We're looking for attributed partial methods. var methodModels = context.SyntaxProvider - .CreateSyntaxProvider( + .CreateSyntaxProvider( static (s, _) => s is MethodDeclarationSyntax { AttributeLists.Count: > 0 } method && method.Modifiers.Any(SyntaxKind.PartialKeyword), static (ctx, ct) => {