-
Notifications
You must be signed in to change notification settings - Fork 232
[azsdk-cli] Add compile-time analyzer to enforce Tool method return types #11565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c49b031
9a6637b
1d0d945
d7673a8
740d80e
c480f2e
2228641
0cc11a9
50dcc81
8b54beb
d2b51bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,2 +1,4 @@ | ||
| [*.cs] | ||
| # disallow single if statements with no curly braces | ||
| dotnet_diagnostic.SA1503.severity = error | ||
| dotnet_diagnostic.IDE0011.severity = error | ||
| dotnet_style_namespace_match_folder = false |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,225 @@ | ||||||||||||||||||||||||||||||||||||||||||||
| using System; | ||||||||||||||||||||||||||||||||||||||||||||
| using System.Collections.Immutable; | ||||||||||||||||||||||||||||||||||||||||||||
| using System.Linq; | ||||||||||||||||||||||||||||||||||||||||||||
| using Microsoft.CodeAnalysis; | ||||||||||||||||||||||||||||||||||||||||||||
| using Microsoft.CodeAnalysis.CSharp; | ||||||||||||||||||||||||||||||||||||||||||||
| using Microsoft.CodeAnalysis.CSharp.Syntax; | ||||||||||||||||||||||||||||||||||||||||||||
| using Microsoft.CodeAnalysis.Diagnostics; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| namespace Azure.Sdk.Tools.Cli.Analyzer | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| [DiagnosticAnalyzer(LanguageNames.CSharp)] | ||||||||||||||||||||||||||||||||||||||||||||
| public class EnforceToolsReturnTypesAnalyzer : DiagnosticAnalyzer | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| public const string Id = "MCP003"; | ||||||||||||||||||||||||||||||||||||||||||||
| public static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor( | ||||||||||||||||||||||||||||||||||||||||||||
| Id, | ||||||||||||||||||||||||||||||||||||||||||||
| "Tool methods must return Response types, built-in value types, or string", | ||||||||||||||||||||||||||||||||||||||||||||
| "Method '{0}' in Tools namespace must return a class implementing Response, a built-in value type, or string. Current return type: '{1}'.", | ||||||||||||||||||||||||||||||||||||||||||||
| "Design", | ||||||||||||||||||||||||||||||||||||||||||||
| DiagnosticSeverity.Error, | ||||||||||||||||||||||||||||||||||||||||||||
| isEnabledByDefault: true); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(Rule); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| public override void Initialize(AnalysisContext context) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); | ||||||||||||||||||||||||||||||||||||||||||||
| context.EnableConcurrentExecution(); | ||||||||||||||||||||||||||||||||||||||||||||
| context.RegisterSyntaxNodeAction(AnalyzeMethod, SyntaxKind.MethodDeclaration); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| private static void AnalyzeMethod(SyntaxNodeAnalysisContext context) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| var methodDeclaration = (MethodDeclarationSyntax)context.Node; | ||||||||||||||||||||||||||||||||||||||||||||
| var semanticModel = context.SemanticModel; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Get method symbol | ||||||||||||||||||||||||||||||||||||||||||||
| if (!(semanticModel.GetDeclaredSymbol(methodDeclaration) is IMethodSymbol methodSymbol)) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Only analyze public non-static methods | ||||||||||||||||||||||||||||||||||||||||||||
| if (!methodSymbol.DeclaredAccessibility.HasFlag(Accessibility.Public) || methodSymbol.IsStatic) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Only analyze methods in classes within Azure.Sdk.Tools.Cli.Tools namespace | ||||||||||||||||||||||||||||||||||||||||||||
| var containingType = methodSymbol.ContainingType; | ||||||||||||||||||||||||||||||||||||||||||||
| if (containingType?.ContainingNamespace == null) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| var namespaceName = containingType.ContainingNamespace.ToDisplayString(); | ||||||||||||||||||||||||||||||||||||||||||||
| if (!namespaceName.StartsWith("Azure.Sdk.Tools.Cli.Tools")) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Exclude abstract methods and virtual methods that are likely from base classes | ||||||||||||||||||||||||||||||||||||||||||||
| if (methodSymbol.IsAbstract || methodSymbol.IsVirtual || methodSymbol.IsOverride) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Exclude specific framework methods by name | ||||||||||||||||||||||||||||||||||||||||||||
| if (methodSymbol.Name == "GetCommand" || methodSymbol.Name == "HandleCommand") | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| var returnType = methodSymbol.ReturnType; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Handle Task<T> - get the inner type | ||||||||||||||||||||||||||||||||||||||||||||
| if (IsTaskType(returnType, out var innerType)) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| returnType = innerType; | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| // Check if return type is valid | ||||||||||||||||||||||||||||||||||||||||||||
| if (!IsValidReturnType(returnType, context.Compilation)) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| var returnTypeDisplayName = returnType.ToDisplayString(); | ||||||||||||||||||||||||||||||||||||||||||||
| var diagnostic = Diagnostic.Create(Rule, | ||||||||||||||||||||||||||||||||||||||||||||
| methodDeclaration.Identifier.GetLocation(), | ||||||||||||||||||||||||||||||||||||||||||||
| methodSymbol.Name, | ||||||||||||||||||||||||||||||||||||||||||||
| returnTypeDisplayName); | ||||||||||||||||||||||||||||||||||||||||||||
| context.ReportDiagnostic(diagnostic); | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| private static bool IsTaskType(ITypeSymbol type, out ITypeSymbol innerType) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| innerType = type; | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| if (type is INamedTypeSymbol namedType) | ||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||
| // Check for Task<T> | ||||||||||||||||||||||||||||||||||||||||||||
| if (namedType.IsGenericType && | ||||||||||||||||||||||||||||||||||||||||||||
| (namedType.ConstructedFrom?.ToDisplayString() == "System.Threading.Tasks.Task<T>" || | ||||||||||||||||||||||||||||||||||||||||||||
| namedType.Name == "Task" && namedType.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks")) | ||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+102
to
+103
|
||||||||||||||||||||||||||||||||||||||||||||
| (namedType.ConstructedFrom?.ToDisplayString() == "System.Threading.Tasks.Task<T>" || | |
| namedType.Name == "Task" && namedType.ContainingNamespace?.ToDisplayString() == "System.Threading.Tasks")) | |
| namedType.ConstructedFrom?.ToDisplayString() == "System.Threading.Tasks.Task<T>") |
Copilot
AI
Aug 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment suggests System_Char is matching against 'string', which indicates a potential logic error. System_Char should match char types, not string. If this is unexpected behavior, the logic needs investigation and the comment should be clarified or the case removed if incorrect.
| case SpecialType.System_Char: // NOTE: this seems to be matching against 'string' for some reason |
Copilot
AI
Aug 7, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using FirstOrDefault() with a complex predicate can be inefficient. Consider using a more specific lookup or caching the constructed generic type definition for IEnumerable to improve performance when analyzing large codebases.
| var ienumerableInterface = returnType.AllInterfaces.FirstOrDefault( | |
| i => i.IsGenericType && | |
| i.ConstructedFrom?.ToDisplayString() == "System.Collections.Generic.IEnumerable<T>"); | |
| if (ienumerableInterface != null && ienumerableInterface.TypeArguments.Length > 0) | |
| { | |
| var elementType = ienumerableInterface.TypeArguments[0]; | |
| var ienumerableTypeSymbol = compilation.GetTypeByMetadataName("System.Collections.Generic.IEnumerable`1"); | |
| if (ienumerableTypeSymbol == null) | |
| { | |
| return false; | |
| } | |
| var ienumerableInterface = returnType.AllInterfaces | |
| .FirstOrDefault(i => i is INamedTypeSymbol named && | |
| named.IsGenericType && | |
| SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, ienumerableTypeSymbol)); | |
| if (ienumerableInterface != null && ((INamedTypeSymbol)ienumerableInterface).TypeArguments.Length > 0) | |
| { | |
| var elementType = ((INamedTypeSymbol)ienumerableInterface).TypeArguments[0]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| # Tool Exception Handling Analyzer (MCP001) | ||
|
|
||
| ## Overview | ||
|
|
||
| The `EnforceToolsExceptionHandlingAnalyzer` ensures that all methods decorated with the `McpServerTool` attribute properly wrap their entire body in try/catch blocks with proper exception handling. | ||
|
|
||
| Unhandled exceptions in MCP mode prevent the client from logging tool responses correctly as the error information is not transmitted through the protocol. Currently there is no middleware support in the MCP C# SDK that could intercept unhandled exceptions. See https://github.com/modelcontextprotocol/csharp-sdk/issues/267 | ||
|
|
||
| ## Rule: MCP001 | ||
|
|
||
| **Title**: McpServerTool methods must wrap body in try/catch | ||
|
|
||
| Methods decorated with `[McpServerTool]` must have their entire body wrapped in a try/catch block that catches `System.Exception`. This ensures consistent error handling across all MCP tools. | ||
|
|
||
| ## Requirements | ||
|
|
||
| 1. **Try/catch wrapping**: The entire method body must be within a try statement | ||
| 2. **Exception type**: Must catch `System.Exception` (not specific exception types) | ||
| 3. **Variable declarations**: Local variable declarations are allowed outside the try block | ||
| 4. **SetFailure() call**: The catch block should call `SetFailure()` to mark the tool as failed | ||
|
|
||
| ## Migration Guide | ||
|
|
||
| When you encounter MCP001 violations: | ||
|
|
||
| ```csharp | ||
| // ❌ Incorrect - no try/catch | ||
| [McpServerTool] | ||
| public async Task<Response> ProcessData(string myArg) | ||
| { | ||
| var parsedArg = myArg.Trim(" "); | ||
| var result = await DoSomething(parsedArg); | ||
| return new Response { Data = result }; | ||
| } | ||
|
|
||
| // ✅ Correct - proper try/catch structure | ||
| [McpServerTool] | ||
| public async Task<Response> ProcessData(string myArg) | ||
| { | ||
| // Variables are allowed to be defined outside the try/catch | ||
| // if they need to be referenced in the catch block | ||
| var parsedArg = myArg.Trim(" "); | ||
|
|
||
| try | ||
| { | ||
| var result = await DoSomething(parsedArg); | ||
| return new Response { Data = result }; | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| SetFailure(); | ||
| logger.LogError(ex, "Error processing data"); | ||
| return new Response { ResponseError = $"Error processing data for {parsedArg}: {ex.Message}" }; | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| # Tool Service Registration Analyzer (MCP002) | ||
|
|
||
| ## Overview | ||
|
|
||
| The `EnforceToolsListAnalyzer` ensures that every class inheriting from `MCPTool` is properly registered in the `SharedOptions.ToolsList` static list, otherwise they will not be loaded at startup. | ||
|
|
||
| ## Rule: MCP002 | ||
|
|
||
| **Title**: Every MCPTool must be listed in SharedOptions.ToolsList | ||
|
|
||
| All non-abstract classes that inherit from `Azure.Sdk.Tools.Cli.Contract.MCPTool` must be included as `typeof(ClassName)` entries in the `SharedOptions.ToolsList` static field (`Azure.Sdk.Tools.Cli/Commands/SharedOptions.cs`). | ||
|
|
||
| ## Requirements | ||
|
|
||
| 1. **Registration**: Every `MCPTool` implementation must appear in `SharedOptions.ToolsList` | ||
| 2. **Typeof syntax**: Tools must be registered using `typeof(YourToolClass)` | ||
| 3. **Non-abstract only**: Only concrete (non-abstract) classes are validated | ||
| 4. **Compile-time check**: This validation happens at compilation end | ||
|
|
||
| ## Migration Guide | ||
|
|
||
| When you encounter MCP002 violations: | ||
|
|
||
| ```csharp | ||
| // 1. Tool implementation | ||
| public class MyCustomTool : MCPTool | ||
| { | ||
| public override Command GetCommand() { /* implementation */ } | ||
| public override Task HandleCommand(InvocationContext ctx, CancellationToken ct) { /* implementation */ } | ||
| } | ||
|
|
||
| // 2. Add it to SharedOptions.ToolsList in Azure.Sdk.Tools.Cli/Commands/SharedOptions.cs | ||
| public static readonly List<Type> ToolsList = [ | ||
| typeof(ExistingTool1), | ||
| typeof(ExistingTool2), | ||
| typeof(MyCustomTool), // ← Add your new tool here | ||
| // ... other tools | ||
| ]; | ||
| ``` | ||
|
|
||
| # Tool Return Type Analyzer (MCP003) | ||
|
|
||
| ## Overview | ||
|
|
||
| The `EnforceToolsReturnTypesAnalyzer` ensures that all public non-static methods in classes within | ||
| the `Azure.Sdk.Tools.Cli.Tools` namespace return only approved types at compile time. | ||
|
|
||
| ## Rule: MCP003 | ||
|
|
||
| **Title**: Tool methods must return Response types, built-in value types, or string | ||
|
|
||
| This excludes inherited methods `GetCommand`, `HandleCommand`, etc. | ||
|
|
||
| ## Allowed Return Types | ||
|
|
||
| 1. Classes implementing `Azure.Sdk.Tools.Cli.Models.Response` | ||
| 1. `string` | ||
| 1. Primitive types (`int`, `bool`, etc.) | ||
| 1. `IEnumerable<T>` of any of the above | ||
| 1. `Task` (for `Task<T>` T must be any of the above) | ||
| 1. `void` | ||
|
|
||
| ## Migration Guide | ||
|
|
||
| When you encounter MCP003 violations: | ||
|
|
||
| **For custom objects**: Make them inherit from Response or wrap in Response | ||
| ```csharp | ||
| // Instead of: | ||
| public async Task<CustomData> GetData() { } | ||
|
|
||
| // Option 1: Make CustomData inherit Response | ||
| public class CustomData : Response { } | ||
|
|
||
| // Option 2: Wrap in Response type | ||
| public async Task<CustomDataResponse> GetData() { } | ||
| ``` | ||
|
|
||
| Exceptions are not handled from top-level tool methods. | ||
| To bubble errors up in a way that can be formatted for supported callers (CLI, MCP, etc.), | ||
| return the custom type and set the inherited `ResponseError` or `ResponseErrors` property: | ||
|
|
||
| ```csharp | ||
| try | ||
| { | ||
| // Tool business logic here | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| SetFailure(); | ||
| logger.LogError(ex, "Error running tool"); | ||
| return new CustomData { | ||
| ResponseError: $"Error running tool: {ex.Message}"; | ||
| } | ||
| } | ||
| ``` |
Uh oh!
There was an error while loading. Please reload this page.