Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c04f198
Add sessions doc and prefer stateless mode in docs, samples, and erro…
halter73 Mar 26, 2026
e63ec21
Fix relative links and address PR review feedback
halter73 Mar 26, 2026
1a8bf45
Remove flaky stateless progress test
halter73 Mar 26, 2026
c9e8ea9
Add decision tree, backpressure warning, and normalize xrefs
halter73 Mar 26, 2026
7e703f3
Explicitly set Stateless in all WithHttpTransport calls
halter73 Mar 26, 2026
20c6816
Document DI service lifetimes and scoping behavior
halter73 Mar 26, 2026
ad14237
Reorganize sessions.md for better flow
halter73 Mar 26, 2026
2d14019
Fix idle session docs and clean up DI scope language
halter73 Mar 26, 2026
7f9aae0
Elevate backpressure warning to top-level security callout
halter73 Mar 26, 2026
b856d84
Refine client compatibility and activity tracking in sessions doc
halter73 Mar 26, 2026
040d26a
Tone down ScopeRequests=false guidance
halter73 Mar 26, 2026
26e3bb5
Soften sessions doc wording
halter73 Mar 26, 2026
d13e0f3
Fix ScopeRequests guidance for pre-scoped providers
halter73 Mar 26, 2026
083cf5b
Replace ScopeRequests sample with McpServer.Create example
halter73 Mar 26, 2026
5735987
Split DI scopes guidance for stdio vs custom transports
halter73 Mar 26, 2026
5737a99
Fix docfx xref namespaces for McpServer.Services and McpServer.Create
halter73 Mar 26, 2026
6dd7a5c
Update InMemoryTransport link to pinned lines
halter73 Mar 26, 2026
9e85bd7
Spruce up transports.md with per-mode comparison table
halter73 Mar 26, 2026
b9fe994
Add in-memory transport section and fix SSE comment wording
halter73 Mar 26, 2026
bed9aec
Fix SSE session ID mechanism in transport comparison table
halter73 Mar 26, 2026
e1625cc
Fix StreamClientTransport xref namespace
halter73 Mar 26, 2026
5f0051e
Apply suggestion from @halter73
halter73 Mar 26, 2026
841a3aa
Add legacy SSE transport coverage to sessions.md
halter73 Mar 26, 2026
04d825d
Add cancellation and disposal section to sessions.md
halter73 Mar 26, 2026
4c9c8fb
Reorder sessions.md: practical sections first, deep-dives last
halter73 Mar 26, 2026
b8f4034
Apply suggestion
halter73 Mar 26, 2026
93eecaa
Add tasks, cancellation, and observability coverage to sessions doc
halter73 Mar 26, 2026
4763a04
Apply suggestion
halter73 Mar 26, 2026
c8159bb
Refine backpressure warning with technical nuance
halter73 Mar 27, 2026
22b4dc1
Move backpressure analysis from top-level warning to detailed section
halter73 Mar 27, 2026
b5a47b5
Add SSE to request backpressure section
halter73 Mar 27, 2026
88bad8b
Disable legacy SSE endpoints by default (MCP9003)
halter73 Mar 27, 2026
62a11b9
Use generic HTTP server terminology instead of Kestrel-specific
halter73 Mar 27, 2026
4062b23
Add SSE backpressure remarks to doc comments
halter73 Mar 27, 2026
b7e68dc
Improve session docs, client behavior, and observability guidance
halter73 Mar 27, 2026
3f71fce
Clarify session intro: recommend stateless, spec-required client beha…
halter73 Mar 27, 2026
4f885d2
Reorganize sessions.md and use endpoint filter with Activity tag
halter73 Mar 27, 2026
61b7172
Add MCP003 analyzer: warn when WithHttpTransport doesn't set Stateless
halter73 Mar 27, 2026
096dcfa
Revert documentation changes
jeffhandley Jul 26, 2026
45b34b2
Merge remote-tracking branch 'origin/main' into halter73/stateless-an…
jeffhandley Jul 26, 2026
a1dc6d2
Handle conditional HTTP transport calls
jeffhandley Jul 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/ModelContextProtocol.Analyzers/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,15 @@ internal static class Diagnostics
defaultSeverity: DiagnosticSeverity.Info,
isEnabledByDefault: true,
description: "Methods with MCP attributes should be declared as partial to allow the source generator to emit Description attributes from XML documentation comments.");

public static DiagnosticDescriptor WithHttpTransportStatelessNotSet { get; } = new(
id: "MCP003",
title: "WithHttpTransport should explicitly configure Stateless property",
messageFormat: "WithHttpTransport is called without explicitly setting HttpServerTransportOptions.Stateless. Set Stateless to true (recommended) or false for forward compatibility.",
category: "mcp",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "WithHttpTransport should explicitly set the Stateless property on HttpServerTransportOptions for forward compatibility. " +
"Stateless mode (recommended for most servers) avoids session state, memory overhead, and deployment constraints. " +
"If your server requires server-to-client requests or unsolicited notifications, set Stateless to false explicitly.");
}
2 changes: 2 additions & 0 deletions src/ModelContextProtocol.Analyzers/McpAttributeNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ internal static class McpAttributeNames
public const string McpServerPromptAttribute = "ModelContextProtocol.Server.McpServerPromptAttribute";
public const string McpServerResourceAttribute = "ModelContextProtocol.Server.McpServerResourceAttribute";
public const string DescriptionAttribute = "System.ComponentModel.DescriptionAttribute";
public const string HttpMcpServerBuilderExtensions = "Microsoft.Extensions.DependencyInjection.HttpMcpServerBuilderExtensions";
public const string HttpServerTransportOptions = "ModelContextProtocol.AspNetCore.HttpServerTransportOptions";
}
290 changes: 290 additions & 0 deletions src/ModelContextProtocol.Analyzers/WithHttpTransportAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Threading;

namespace ModelContextProtocol.Analyzers;

/// <summary>
/// Reports a warning when <c>WithHttpTransport</c> is called without explicitly setting
/// <c>HttpServerTransportOptions.Stateless</c> to <see langword="true"/> or <see langword="false"/>.
/// </summary>
/// <remarks>
/// <para>
/// The default value of <c>Stateless</c> can change with protocol revisions. Setting the property explicitly
/// protects against future changes to the default, while stateless mode remains recommended for most servers.
/// </para>
/// <para>
/// The analyzer detects the following patterns:
/// <list type="bullet">
/// <item><c>.WithHttpTransport()</c> — no delegate passed.</item>
/// <item><c>.WithHttpTransport(null)</c> — null literal passed.</item>
/// <item><c>.WithHttpTransport(o =&gt; ...)</c> — lambda that does not assign to <c>o.Stateless</c>.</item>
/// <item><c>.WithHttpTransport(ConfigureMethod)</c> — method group (cannot trace into the body).</item>
/// </list>
/// </para>
/// </remarks>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class WithHttpTransportAnalyzer : DiagnosticAnalyzer
{
private const string WithHttpTransportMethodName = "WithHttpTransport";
private const string StatelessPropertyName = "Stateless";

/// <inheritdoc/>
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Diagnostics.WithHttpTransportStatelessNotSet);

/// <inheritdoc/>
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();

context.RegisterCompilationStartAction(compilationContext =>
{
INamedTypeSymbol? extensionsType = compilationContext.Compilation.GetTypeByMetadataName(
McpAttributeNames.HttpMcpServerBuilderExtensions);

if (extensionsType is null)
{
// The AspNetCore package isn't referenced; nothing to analyze.
return;
}

INamedTypeSymbol? optionsType = compilationContext.Compilation.GetTypeByMetadataName(
McpAttributeNames.HttpServerTransportOptions);

if (optionsType is null)
{
return;
}

compilationContext.RegisterSyntaxNodeAction(
nodeContext => AnalyzeInvocation(nodeContext, extensionsType, optionsType),
SyntaxKind.InvocationExpression);
});
}

private static void AnalyzeInvocation(
SyntaxNodeAnalysisContext context,
INamedTypeSymbol extensionsType,
INamedTypeSymbol optionsType)
{
var invocation = (InvocationExpressionSyntax)context.Node;

// Quick syntactic check: does the method name end with "WithHttpTransport"?
string? methodName = GetMethodName(invocation);
if (methodName != WithHttpTransportMethodName)
{
return;
}

// Resolve the method symbol to confirm it's the right one.
if (context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol is not IMethodSymbol methodSymbol)
{
return;
}

// Check that the method belongs to HttpMcpServerBuilderExtensions (comparing the original definition for generic cases).
IMethodSymbol originalMethod = methodSymbol.ReducedFrom ?? methodSymbol;
if (!SymbolEqualityComparer.Default.Equals(originalMethod.ContainingType, extensionsType))
{
return;
}

// Determine the configureOptions argument.
bool isExtensionInvocation = methodSymbol.ReducedFrom is not null;
ExpressionSyntax? configureArg = GetConfigureOptionsArgument(invocation, originalMethod, isExtensionInvocation);

if (configureArg is null || IsNullLiteral(configureArg))
{
// No delegate or explicit null — warn.
context.ReportDiagnostic(Diagnostic.Create(
Diagnostics.WithHttpTransportStatelessNotSet,
invocation.GetLocation()));
return;
}

// If the argument is a lambda or anonymous method, check whether it assigns to Stateless.
if (configureArg is AnonymousFunctionExpressionSyntax lambda)
{
if (!LambdaAssignsStateless(lambda, context.SemanticModel, optionsType, context.CancellationToken))
{
context.ReportDiagnostic(Diagnostic.Create(
Diagnostics.WithHttpTransportStatelessNotSet,
invocation.GetLocation()));
}
return;
}

// For method groups, delegate variables, or other expressions we cannot analyze easily — warn.
context.ReportDiagnostic(Diagnostic.Create(
Diagnostics.WithHttpTransportStatelessNotSet,
invocation.GetLocation()));
}

private static string? GetMethodName(InvocationExpressionSyntax invocation)
{
return invocation.Expression switch
{
MemberAccessExpressionSyntax memberAccess => memberAccess.Name.Identifier.Text,
MemberBindingExpressionSyntax memberBinding => memberBinding.Name.Identifier.Text,
IdentifierNameSyntax identifier => identifier.Identifier.Text,
_ => null,
};
}

/// <summary>
/// Extracts the <c>configureOptions</c> argument from the invocation, handling both positional and named arguments.
/// Returns <see langword="null"/> if no argument was provided (relying on the default <c>null</c>).
/// </summary>
private static ExpressionSyntax? GetConfigureOptionsArgument(
InvocationExpressionSyntax invocation,
IMethodSymbol originalMethod,
bool isExtensionInvocation)
{
ArgumentListSyntax argumentList = invocation.ArgumentList;

// If called as an extension method via member access (builder.WithHttpTransport(...)),
// the configureOptions parameter is at index 1 in the original definition but index 0 in the call.
// If called as a static method (HttpMcpServerBuilderExtensions.WithHttpTransport(builder, ...)),
// it's at index 1 in the call.
// We match by parameter name for robustness.

foreach (ArgumentSyntax argument in argumentList.Arguments)
{
if (argument.NameColon is not null)
{
if (argument.NameColon.Name.Identifier.Text == "configureOptions")
{
return argument.Expression;
}
}
}

// No named argument found — try positional.
// Find the parameter index of configureOptions in the original method.
int paramIndex = -1;
for (int i = 0; i < originalMethod.Parameters.Length; i++)
{
if (originalMethod.Parameters[i].Name == "configureOptions")
{
paramIndex = i;
break;
}
}

if (paramIndex < 0)
{
return null;
}

// Adjust index: if called as an extension method (builder.WithHttpTransport(...)),
// the 'this' parameter is implicit in the call args.
int callArgIndex = isExtensionInvocation ? paramIndex - 1 : paramIndex;

if (callArgIndex >= 0 && callArgIndex < argumentList.Arguments.Count)
{
return argumentList.Arguments[callArgIndex].Expression;
}

return null;
}

private static bool IsNullLiteral(ExpressionSyntax expression)
{
// Handle both 'null' and 'default' / 'default(Action<HttpServerTransportOptions>)'.
return expression.IsKind(SyntaxKind.NullLiteralExpression) ||
expression.IsKind(SyntaxKind.DefaultLiteralExpression) ||
expression.IsKind(SyntaxKind.DefaultExpression);
}

/// <summary>
/// Checks whether a lambda or anonymous method assigns to the <c>Stateless</c> property
/// on its parameter (which should be of type <c>HttpServerTransportOptions</c>).
/// </summary>
private static bool LambdaAssignsStateless(
AnonymousFunctionExpressionSyntax lambda,
SemanticModel semanticModel,
INamedTypeSymbol optionsType,
CancellationToken cancellationToken)
{
// Get the parameter name used by the lambda. For simple lambdas (o => ...),
// ParenthesizedLambda (Action<T> can have (options) => ...), or anonymous delegates.
string? parameterName = GetLambdaParameterName(lambda);

// Walk all descendant assignment expressions looking for assignments to <param>.Stateless.
foreach (AssignmentExpressionSyntax assignment in lambda.DescendantNodes().OfType<AssignmentExpressionSyntax>())
{
if (IsStatelessAssignment(assignment, parameterName, semanticModel, optionsType, cancellationToken))
{
return true;
}
}

return false;
}

private static string? GetLambdaParameterName(AnonymousFunctionExpressionSyntax lambda)
{
if (lambda is SimpleLambdaExpressionSyntax simpleLambda)
{
return simpleLambda.Parameter.Identifier.Text;
}

if (lambda is ParenthesizedLambdaExpressionSyntax parenthesizedLambda &&
parenthesizedLambda.ParameterList.Parameters.Count > 0)
{
return parenthesizedLambda.ParameterList.Parameters[0].Identifier.Text;
}

if (lambda is AnonymousMethodExpressionSyntax anonymousMethod &&
anonymousMethod.ParameterList is not null &&
anonymousMethod.ParameterList.Parameters.Count > 0)
{
return anonymousMethod.ParameterList.Parameters[0].Identifier.Text;
}

return null;
}

/// <summary>
/// Determines whether an assignment expression targets the <c>Stateless</c> property
/// on <c>HttpServerTransportOptions</c>. Uses a fast syntactic check first, then confirms
/// via the semantic model.
/// </summary>
private static bool IsStatelessAssignment(
AssignmentExpressionSyntax assignment,
string? parameterName,
SemanticModel semanticModel,
INamedTypeSymbol optionsType,
CancellationToken cancellationToken)
{
// Fast syntactic check: is the left side <something>.Stateless?
if (assignment.Left is not MemberAccessExpressionSyntax memberAccess ||
memberAccess.Name.Identifier.Text != StatelessPropertyName)
{
return false;
}

// If we know the parameter name, check that the receiver matches.
if (parameterName is not null &&
memberAccess.Expression is IdentifierNameSyntax identifier &&
identifier.Identifier.Text != parameterName)
{
return false;
}

// Confirm via semantic model that the property belongs to HttpServerTransportOptions.
SymbolInfo symbolInfo = semanticModel.GetSymbolInfo(memberAccess, cancellationToken);
if (symbolInfo.Symbol is IPropertySymbol propertySymbol &&
SymbolEqualityComparer.Default.Equals(propertySymbol.ContainingType, optionsType))
{
return true;
}

return false;
}
}
Loading
Loading