|
| 1 | +using Microsoft.CodeAnalysis; |
| 2 | +using Microsoft.CodeAnalysis.CSharp; |
| 3 | +using Microsoft.CodeAnalysis.CSharp.Syntax; |
| 4 | +using Microsoft.CodeAnalysis.Diagnostics; |
| 5 | +using System.Collections.Immutable; |
| 6 | +using System.Threading; |
| 7 | + |
| 8 | +namespace ModelContextProtocol.Analyzers; |
| 9 | + |
| 10 | +/// <summary> |
| 11 | +/// Reports a warning when <c>WithHttpTransport</c> is called without explicitly setting |
| 12 | +/// <c>HttpServerTransportOptions.Stateless</c> to <see langword="true"/> or <see langword="false"/>. |
| 13 | +/// </summary> |
| 14 | +/// <remarks> |
| 15 | +/// <para> |
| 16 | +/// The default value of <c>Stateless</c> is <see langword="false"/> (stateful mode), but stateless mode is |
| 17 | +/// recommended for most servers. Setting the property explicitly protects against future changes to the default. |
| 18 | +/// </para> |
| 19 | +/// <para> |
| 20 | +/// The analyzer detects the following patterns: |
| 21 | +/// <list type="bullet"> |
| 22 | +/// <item><c>.WithHttpTransport()</c> — no delegate passed.</item> |
| 23 | +/// <item><c>.WithHttpTransport(null)</c> — null literal passed.</item> |
| 24 | +/// <item><c>.WithHttpTransport(o => ...)</c> — lambda that does not assign to <c>o.Stateless</c>.</item> |
| 25 | +/// <item><c>.WithHttpTransport(ConfigureMethod)</c> — method group (cannot trace into the body).</item> |
| 26 | +/// </list> |
| 27 | +/// </para> |
| 28 | +/// </remarks> |
| 29 | +[DiagnosticAnalyzer(LanguageNames.CSharp)] |
| 30 | +public sealed class WithHttpTransportAnalyzer : DiagnosticAnalyzer |
| 31 | +{ |
| 32 | + private const string WithHttpTransportMethodName = "WithHttpTransport"; |
| 33 | + private const string StatelessPropertyName = "Stateless"; |
| 34 | + |
| 35 | + /// <inheritdoc/> |
| 36 | + public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => |
| 37 | + ImmutableArray.Create(Diagnostics.WithHttpTransportStatelessNotSet); |
| 38 | + |
| 39 | + /// <inheritdoc/> |
| 40 | + public override void Initialize(AnalysisContext context) |
| 41 | + { |
| 42 | + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); |
| 43 | + context.EnableConcurrentExecution(); |
| 44 | + |
| 45 | + context.RegisterCompilationStartAction(compilationContext => |
| 46 | + { |
| 47 | + INamedTypeSymbol? extensionsType = compilationContext.Compilation.GetTypeByMetadataName( |
| 48 | + McpAttributeNames.HttpMcpServerBuilderExtensions); |
| 49 | + |
| 50 | + if (extensionsType is null) |
| 51 | + { |
| 52 | + // The AspNetCore package isn't referenced; nothing to analyze. |
| 53 | + return; |
| 54 | + } |
| 55 | + |
| 56 | + INamedTypeSymbol? optionsType = compilationContext.Compilation.GetTypeByMetadataName( |
| 57 | + McpAttributeNames.HttpServerTransportOptions); |
| 58 | + |
| 59 | + if (optionsType is null) |
| 60 | + { |
| 61 | + return; |
| 62 | + } |
| 63 | + |
| 64 | + compilationContext.RegisterSyntaxNodeAction( |
| 65 | + nodeContext => AnalyzeInvocation(nodeContext, extensionsType, optionsType), |
| 66 | + SyntaxKind.InvocationExpression); |
| 67 | + }); |
| 68 | + } |
| 69 | + |
| 70 | + private static void AnalyzeInvocation( |
| 71 | + SyntaxNodeAnalysisContext context, |
| 72 | + INamedTypeSymbol extensionsType, |
| 73 | + INamedTypeSymbol optionsType) |
| 74 | + { |
| 75 | + var invocation = (InvocationExpressionSyntax)context.Node; |
| 76 | + |
| 77 | + // Quick syntactic check: does the method name end with "WithHttpTransport"? |
| 78 | + string? methodName = GetMethodName(invocation); |
| 79 | + if (methodName != WithHttpTransportMethodName) |
| 80 | + { |
| 81 | + return; |
| 82 | + } |
| 83 | + |
| 84 | + // Resolve the method symbol to confirm it's the right one. |
| 85 | + if (context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol is not IMethodSymbol methodSymbol) |
| 86 | + { |
| 87 | + return; |
| 88 | + } |
| 89 | + |
| 90 | + // Check that the method belongs to HttpMcpServerBuilderExtensions (comparing the original definition for generic cases). |
| 91 | + IMethodSymbol originalMethod = methodSymbol.ReducedFrom ?? methodSymbol; |
| 92 | + if (!SymbolEqualityComparer.Default.Equals(originalMethod.ContainingType, extensionsType)) |
| 93 | + { |
| 94 | + return; |
| 95 | + } |
| 96 | + |
| 97 | + // Determine the configureOptions argument. |
| 98 | + bool isExtensionInvocation = methodSymbol.ReducedFrom is not null; |
| 99 | + ExpressionSyntax? configureArg = GetConfigureOptionsArgument(invocation, originalMethod, isExtensionInvocation); |
| 100 | + |
| 101 | + if (configureArg is null || IsNullLiteral(configureArg)) |
| 102 | + { |
| 103 | + // No delegate or explicit null — warn. |
| 104 | + context.ReportDiagnostic(Diagnostic.Create( |
| 105 | + Diagnostics.WithHttpTransportStatelessNotSet, |
| 106 | + invocation.GetLocation())); |
| 107 | + return; |
| 108 | + } |
| 109 | + |
| 110 | + // If the argument is a lambda or anonymous method, check whether it assigns to Stateless. |
| 111 | + if (configureArg is AnonymousFunctionExpressionSyntax lambda) |
| 112 | + { |
| 113 | + if (!LambdaAssignsStateless(lambda, context.SemanticModel, optionsType, context.CancellationToken)) |
| 114 | + { |
| 115 | + context.ReportDiagnostic(Diagnostic.Create( |
| 116 | + Diagnostics.WithHttpTransportStatelessNotSet, |
| 117 | + invocation.GetLocation())); |
| 118 | + } |
| 119 | + return; |
| 120 | + } |
| 121 | + |
| 122 | + // For method groups, delegate variables, or other expressions we cannot analyze easily — warn. |
| 123 | + context.ReportDiagnostic(Diagnostic.Create( |
| 124 | + Diagnostics.WithHttpTransportStatelessNotSet, |
| 125 | + invocation.GetLocation())); |
| 126 | + } |
| 127 | + |
| 128 | + private static string? GetMethodName(InvocationExpressionSyntax invocation) |
| 129 | + { |
| 130 | + return invocation.Expression switch |
| 131 | + { |
| 132 | + MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text, |
| 133 | + IdentifierNameSyntax identifier => identifier.Identifier.Text, |
| 134 | + _ => null, |
| 135 | + }; |
| 136 | + } |
| 137 | + |
| 138 | + /// <summary> |
| 139 | + /// Extracts the <c>configureOptions</c> argument from the invocation, handling both positional and named arguments. |
| 140 | + /// Returns <see langword="null"/> if no argument was provided (relying on the default <c>null</c>). |
| 141 | + /// </summary> |
| 142 | + private static ExpressionSyntax? GetConfigureOptionsArgument( |
| 143 | + InvocationExpressionSyntax invocation, |
| 144 | + IMethodSymbol originalMethod, |
| 145 | + bool isExtensionInvocation) |
| 146 | + { |
| 147 | + ArgumentListSyntax argumentList = invocation.ArgumentList; |
| 148 | + |
| 149 | + // If called as an extension method via member access (builder.WithHttpTransport(...)), |
| 150 | + // the configureOptions parameter is at index 1 in the original definition but index 0 in the call. |
| 151 | + // If called as a static method (HttpMcpServerBuilderExtensions.WithHttpTransport(builder, ...)), |
| 152 | + // it's at index 1 in the call. |
| 153 | + // We match by parameter name for robustness. |
| 154 | + |
| 155 | + foreach (ArgumentSyntax argument in argumentList.Arguments) |
| 156 | + { |
| 157 | + if (argument.NameColon is not null) |
| 158 | + { |
| 159 | + if (argument.NameColon.Name.Identifier.Text == "configureOptions") |
| 160 | + { |
| 161 | + return argument.Expression; |
| 162 | + } |
| 163 | + } |
| 164 | + } |
| 165 | + |
| 166 | + // No named argument found — try positional. |
| 167 | + // Find the parameter index of configureOptions in the original method. |
| 168 | + int paramIndex = -1; |
| 169 | + for (int i = 0; i < originalMethod.Parameters.Length; i++) |
| 170 | + { |
| 171 | + if (originalMethod.Parameters[i].Name == "configureOptions") |
| 172 | + { |
| 173 | + paramIndex = i; |
| 174 | + break; |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + if (paramIndex < 0) |
| 179 | + { |
| 180 | + return null; |
| 181 | + } |
| 182 | + |
| 183 | + // Adjust index: if called as an extension method (builder.WithHttpTransport(...)), |
| 184 | + // the 'this' parameter is implicit in the call args. |
| 185 | + int callArgIndex = isExtensionInvocation ? paramIndex - 1 : paramIndex; |
| 186 | + |
| 187 | + if (callArgIndex >= 0 && callArgIndex < argumentList.Arguments.Count) |
| 188 | + { |
| 189 | + return argumentList.Arguments[callArgIndex].Expression; |
| 190 | + } |
| 191 | + |
| 192 | + return null; |
| 193 | + } |
| 194 | + |
| 195 | + private static bool IsNullLiteral(ExpressionSyntax expression) |
| 196 | + { |
| 197 | + // Handle both 'null' and 'default' / 'default(Action<HttpServerTransportOptions>)'. |
| 198 | + return expression.IsKind(SyntaxKind.NullLiteralExpression) || |
| 199 | + expression.IsKind(SyntaxKind.DefaultLiteralExpression) || |
| 200 | + expression.IsKind(SyntaxKind.DefaultExpression); |
| 201 | + } |
| 202 | + |
| 203 | + /// <summary> |
| 204 | + /// Checks whether a lambda or anonymous method assigns to the <c>Stateless</c> property |
| 205 | + /// on its parameter (which should be of type <c>HttpServerTransportOptions</c>). |
| 206 | + /// </summary> |
| 207 | + private static bool LambdaAssignsStateless( |
| 208 | + AnonymousFunctionExpressionSyntax lambda, |
| 209 | + SemanticModel semanticModel, |
| 210 | + INamedTypeSymbol optionsType, |
| 211 | + CancellationToken cancellationToken) |
| 212 | + { |
| 213 | + // Get the parameter name used by the lambda. For simple lambdas (o => ...), |
| 214 | + // ParenthesizedLambda (Action<T> can have (options) => ...), or anonymous delegates. |
| 215 | + string? parameterName = GetLambdaParameterName(lambda); |
| 216 | + |
| 217 | + // Walk all descendant assignment expressions looking for assignments to <param>.Stateless. |
| 218 | + foreach (AssignmentExpressionSyntax assignment in lambda.DescendantNodes().OfType<AssignmentExpressionSyntax>()) |
| 219 | + { |
| 220 | + if (IsStatelessAssignment(assignment, parameterName, semanticModel, optionsType, cancellationToken)) |
| 221 | + { |
| 222 | + return true; |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + return false; |
| 227 | + } |
| 228 | + |
| 229 | + private static string? GetLambdaParameterName(AnonymousFunctionExpressionSyntax lambda) |
| 230 | + { |
| 231 | + if (lambda is SimpleLambdaExpressionSyntax simpleLambda) |
| 232 | + { |
| 233 | + return simpleLambda.Parameter.Identifier.Text; |
| 234 | + } |
| 235 | + |
| 236 | + if (lambda is ParenthesizedLambdaExpressionSyntax parenthesizedLambda && |
| 237 | + parenthesizedLambda.ParameterList.Parameters.Count > 0) |
| 238 | + { |
| 239 | + return parenthesizedLambda.ParameterList.Parameters[0].Identifier.Text; |
| 240 | + } |
| 241 | + |
| 242 | + if (lambda is AnonymousMethodExpressionSyntax anonymousMethod && |
| 243 | + anonymousMethod.ParameterList is not null && |
| 244 | + anonymousMethod.ParameterList.Parameters.Count > 0) |
| 245 | + { |
| 246 | + return anonymousMethod.ParameterList.Parameters[0].Identifier.Text; |
| 247 | + } |
| 248 | + |
| 249 | + return null; |
| 250 | + } |
| 251 | + |
| 252 | + /// <summary> |
| 253 | + /// Determines whether an assignment expression targets the <c>Stateless</c> property |
| 254 | + /// on <c>HttpServerTransportOptions</c>. Uses a fast syntactic check first, then confirms |
| 255 | + /// via the semantic model. |
| 256 | + /// </summary> |
| 257 | + private static bool IsStatelessAssignment( |
| 258 | + AssignmentExpressionSyntax assignment, |
| 259 | + string? parameterName, |
| 260 | + SemanticModel semanticModel, |
| 261 | + INamedTypeSymbol optionsType, |
| 262 | + CancellationToken cancellationToken) |
| 263 | + { |
| 264 | + // Fast syntactic check: is the left side <something>.Stateless? |
| 265 | + if (assignment.Left is not MemberAccessExpressionSyntax memberAccess || |
| 266 | + memberAccess.Name.Identifier.Text != StatelessPropertyName) |
| 267 | + { |
| 268 | + return false; |
| 269 | + } |
| 270 | + |
| 271 | + // If we know the parameter name, check that the receiver matches. |
| 272 | + if (parameterName is not null && |
| 273 | + memberAccess.Expression is IdentifierNameSyntax identifier && |
| 274 | + identifier.Identifier.Text != parameterName) |
| 275 | + { |
| 276 | + return false; |
| 277 | + } |
| 278 | + |
| 279 | + // Confirm via semantic model that the property belongs to HttpServerTransportOptions. |
| 280 | + SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(memberAccess, cancellationToken); |
| 281 | + if (symbolInfo.Symbol is IPropertySymbol propertySymbol && |
| 282 | + SymbolEqualityComparer.Default.Equals(propertySymbol.ContainingType, optionsType)) |
| 283 | + { |
| 284 | + return true; |
| 285 | + } |
| 286 | + |
| 287 | + return false; |
| 288 | + } |
| 289 | +} |
0 commit comments