Skip to content

Commit 61b7172

Browse files
halter73Copilot
andcommitted
Add MCP003 analyzer: warn when WithHttpTransport doesn't set Stateless
Add a Roslyn DiagnosticAnalyzer (MCP003) that warns when WithHttpTransport is called without explicitly setting HttpServerTransportOptions.Stateless. This protects users from breaking changes if the default changes from stateful to stateless in the future. The analyzer detects: - No delegate passed: .WithHttpTransport() - Null literal: .WithHttpTransport(null) - Lambda without Stateless assignment - Method groups and delegate variables (cannot trace) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4f885d2 commit 61b7172

4 files changed

Lines changed: 695 additions & 0 deletions

File tree

src/ModelContextProtocol.Analyzers/Diagnostics.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,15 @@ internal static class Diagnostics
3636
defaultSeverity: DiagnosticSeverity.Info,
3737
isEnabledByDefault: true,
3838
description: "Methods with MCP attributes should be declared as partial to allow the source generator to emit Description attributes from XML documentation comments.");
39+
40+
public static DiagnosticDescriptor WithHttpTransportStatelessNotSet { get; } = new(
41+
id: "MCP003",
42+
title: "WithHttpTransport should explicitly configure Stateless property",
43+
messageFormat: "WithHttpTransport is called without explicitly setting HttpServerTransportOptions.Stateless. Set Stateless to true (recommended) or false for forward compatibility.",
44+
category: "mcp",
45+
defaultSeverity: DiagnosticSeverity.Warning,
46+
isEnabledByDefault: true,
47+
description: "WithHttpTransport should explicitly set the Stateless property on HttpServerTransportOptions for forward compatibility. " +
48+
"Stateless mode (recommended for most servers) avoids session state, memory overhead, and deployment constraints. " +
49+
"If your server requires server-to-client requests or unsolicited notifications, set Stateless to false explicitly.");
3950
}

src/ModelContextProtocol.Analyzers/McpAttributeNames.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ internal static class McpAttributeNames
99
public const string McpServerPromptAttribute = "ModelContextProtocol.Server.McpServerPromptAttribute";
1010
public const string McpServerResourceAttribute = "ModelContextProtocol.Server.McpServerResourceAttribute";
1111
public const string DescriptionAttribute = "System.ComponentModel.DescriptionAttribute";
12+
public const string HttpMcpServerBuilderExtensions = "Microsoft.Extensions.DependencyInjection.HttpMcpServerBuilderExtensions";
13+
public const string HttpServerTransportOptions = "ModelContextProtocol.AspNetCore.HttpServerTransportOptions";
1214
}
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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 =&gt; ...)</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

Comments
 (0)