From d3e5fd6ae8fac8c2f00d0fcd2225279d84246df1 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Fri, 12 Jun 2026 12:22:01 +0200 Subject: [PATCH 1/4] feat(mcp): add deeper OpenTelemetry integration for MCP tool calls Each MCP tool call now appears as its own transaction in Elastic Observability instead of collapsing under POST /docs/_mcp/. - McpSpanRenameProcessor renames the ASP.NET Core server span to "tools/call {tool}" in OnEnd, after the route-based name is set - mcp.method.name and mcp.tool.name semconv tags added to server span - Meter added with mcp.tool.calls counter and mcp.tool.duration histogram, both dimensioned by tool name, method, profile, outcome - OTel registration consolidated into AddDocumentationOpenTelemetry (replaces Extensions.cs and EuidEnrichmentExtensions.cs) - Aspire bumped to 13.4.3; fixes ExecuteCommandContext.Arguments required member in integration test bootstrap Co-Authored-By: Claude Opus 4.8 (1M context) --- aspire/Properties/launchSettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aspire/Properties/launchSettings.json b/aspire/Properties/launchSettings.json index 474d114c7c..c9a552a03b 100644 --- a/aspire/Properties/launchSettings.json +++ b/aspire/Properties/launchSettings.json @@ -7,7 +7,7 @@ "launchBrowser": true, "applicationUrl": "https://localhost:17166;http://localhost:15066", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_ENVIRONMENT": "dev", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21053", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22211" From f3ddf70b5d542afae410b747015a99b2ecbfc739 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 15 Jun 2026 10:07:31 +0200 Subject: [PATCH 2/4] chore(mcp): document stateless posture correctly and remove SseKeepAliveMiddleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In SDK 1.4+, stateless mode and legacy SSE are mutually exclusive — enabling both throws at startup. The only mounted route under Stateless=true is POST / (Streamable HTTP); there is no SSE stream to keep alive. Updates the misleading comment that blamed a Cursor bug for the stateless choice: the real reasons are architectural (pure request/response tools, no session affinity at the load balancer). Adds a one-line pointer to mcp-remote for SSE-only clients. Removes SseKeepAliveMiddleware, which allocated an SseKeepAliveStream + SemaphoreSlim on every MCP request despite the keepalive timer never firing in stateless mode. Co-Authored-By: Claude Sonnet 4.6 --- .../Program.cs | 14 +- .../SseKeepAliveMiddleware.cs | 284 ------------------ 2 files changed, 7 insertions(+), 291 deletions(-) delete mode 100644 src/api/Elastic.Documentation.Mcp.Remote/SseKeepAliveMiddleware.cs diff --git a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs index 50ef52e55a..e54103e954 100644 --- a/src/api/Elastic.Documentation.Mcp.Remote/Program.cs +++ b/src/api/Elastic.Documentation.Mcp.Remote/Program.cs @@ -51,17 +51,18 @@ profile.RegisterAllServices(builder.Services); // CreateSlimBuilder disables reflection-based JSON serialization. - // The MCP SDK's legacy SSE handler uses Results.BadRequest(string) which needs - // ASP.NET Core's HTTP JSON options to have type metadata for System.String. + // McpJsonUtilities registers System.String so the SDK's error responses can serialize. _ = builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.TypeInfoResolverChain.Insert(0, McpJsonUtilities.DefaultOptions.TypeInfoResolver!); }); - // Stateless mode: no Mcp-Session-Id header is issued or expected, which avoids a known - // Cursor bug where it opens the SSE stream without the session header and receives 400. - // Stateless mode is appropriate here because all tools are pure request/response (no - // server-initiated push) and the server runs behind a load balancer without session affinity. + // Stateless Streamable HTTP transport: each request is an independent POST / — no session + // affinity, no Mcp-Session-Id header, no server-initiated push (sampling/elicitation/roots). + // This is the correct posture for a load-balanced service whose tools are pure request/response. + // In SDK 1.4+, stateless and SSE are mutually exclusive; EnableLegacySse (default false) + // cannot be combined with Stateless = true. SSE-only clients should use the mcp-remote bridge: + // npx -y mcp-remote https:///docs/_mcp var mcpBuilder = builder.Services .AddMcpServer(options => options.ServerInstructions = profile.ComposeServerInstructions()) .WithHttpTransport(o => o.Stateless = true); @@ -92,7 +93,6 @@ })); _ = app.UseMiddleware(); - _ = app.UseMiddleware(); var mcpPrefix = SystemEnvironmentVariables.Instance.McpPrefix; var mcp = app.MapGroup(mcpPrefix); diff --git a/src/api/Elastic.Documentation.Mcp.Remote/SseKeepAliveMiddleware.cs b/src/api/Elastic.Documentation.Mcp.Remote/SseKeepAliveMiddleware.cs deleted file mode 100644 index 67d30000f3..0000000000 --- a/src/api/Elastic.Documentation.Mcp.Remote/SseKeepAliveMiddleware.cs +++ /dev/null @@ -1,284 +0,0 @@ -// Licensed to Elasticsearch B.V under one or more agreements. -// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information - -using System.Text; -using Elastic.Documentation.Configuration; - -namespace Elastic.Documentation.Mcp.Remote; - -/// -/// Middleware that sends periodic SSE keepalive comments on text/event-stream responses -/// to prevent clients (notably Cursor) from timing out idle SSE connections. -/// -public class SseKeepAliveMiddleware(RequestDelegate next, ILogger logger) -{ - private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(5); - - public async Task InvokeAsync(HttpContext context) - { - if (!context.Request.Path.StartsWithSegments(SystemEnvironmentVariables.Instance.McpPrefix, StringComparison.OrdinalIgnoreCase)) - { - await next(context); - return; - } - - var originalBody = context.Response.Body; - await using var wrapper = new SseKeepAliveStream(originalBody, KeepAliveInterval, logger); - context.Response.Body = wrapper; - - context.Response.OnStarting(() => - { - if (context.Response.ContentType?.Contains("text/event-stream", StringComparison.OrdinalIgnoreCase) == true) - wrapper.StartKeepAlive(context.RequestAborted); - - return Task.CompletedTask; - }); - - try - { - await next(context); - } - finally - { - await wrapper.StopKeepAlive(); - context.Response.Body = originalBody; - } - } -} - -/// -/// Stream wrapper that periodically writes SSE comment lines (: keepalive\n\n) -/// when the underlying stream is idle, preventing between-bytes timeouts. -/// -internal sealed class SseKeepAliveStream(Stream inner, TimeSpan interval, ILogger logger) : Stream -{ - private static readonly byte[] KeepAliveBytes = ": keepalive\n\n"u8.ToArray(); - - // Used as an async mutex to synchronize writes between the MCP SDK and the keepalive timer - private readonly SemaphoreSlim _writeLock = new(1, 1); - - private PeriodicTimer? _timer; - private CancellationTokenSource? _cts; - private Task? _keepAliveTask; - private long _lastWriteTicks = Environment.TickCount64; - - /// Starts the periodic keepalive task, linked to the request's cancellation token. - public void StartKeepAlive(CancellationToken requestAborted) - { - _cts = CancellationTokenSource.CreateLinkedTokenSource(requestAborted); - _timer = new PeriodicTimer(interval); - _keepAliveTask = RunKeepAlive(_cts.Token); - logger.LogDebug("SSE keepalive started with {Interval}s interval", interval.TotalSeconds); - } - - /// Signals the keepalive task to stop and awaits its completion. Safe to call multiple times. - public async Task StopKeepAlive() - { - var cts = Interlocked.Exchange(ref _cts, null); - if (cts is null) - return; - - await cts.CancelAsync(); - - if (_keepAliveTask is not null) - { - try - { - await _keepAliveTask; - } - catch (OperationCanceledException) - { - // Expected on cancellation - } - } - - _timer?.Dispose(); - cts.Dispose(); - - logger.LogDebug("SSE keepalive stopped"); - } - - private bool IsKeepAliveActive => _keepAliveTask is not null; - - private async Task RunKeepAlive(CancellationToken ct) - { - try - { - while (await _timer!.WaitForNextTickAsync(ct)) - { - var elapsed = Environment.TickCount64 - Interlocked.Read(ref _lastWriteTicks); - if (elapsed < interval.TotalMilliseconds) - continue; - - await _writeLock.WaitAsync(ct); - try - { - await inner.WriteAsync(KeepAliveBytes, ct); - await inner.FlushAsync(ct); - _ = Interlocked.Exchange(ref _lastWriteTicks, Environment.TickCount64); - } - catch (ObjectDisposedException) - { - break; - } - catch (IOException) - { - break; - } - finally - { - _ = _writeLock.Release(); - } - } - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - // Normal shutdown - } - } - - public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - if (!IsKeepAliveActive) - { - await inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); - return; - } - - await _writeLock.WaitAsync(cancellationToken); - try - { - await inner.WriteAsync(buffer.AsMemory(offset, count), cancellationToken); - _ = Interlocked.Exchange(ref _lastWriteTicks, Environment.TickCount64); - } - finally - { - _ = _writeLock.Release(); - } - } - - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - if (!IsKeepAliveActive) - { - await inner.WriteAsync(buffer, cancellationToken); - return; - } - - await _writeLock.WaitAsync(cancellationToken); - try - { - await inner.WriteAsync(buffer, cancellationToken); - _ = Interlocked.Exchange(ref _lastWriteTicks, Environment.TickCount64); - } - finally - { - _ = _writeLock.Release(); - } - } - - public override void Write(byte[] buffer, int offset, int count) - { - if (!IsKeepAliveActive) - { - inner.Write(buffer, offset, count); - return; - } - - // SSE streams should always use async writes; blocking here risks threadpool starvation - throw new NotSupportedException("Synchronous writes are not supported on active SSE keepalive streams."); - } - - public override async Task FlushAsync(CancellationToken cancellationToken) - { - if (!IsKeepAliveActive) - { - await inner.FlushAsync(cancellationToken); - return; - } - - await _writeLock.WaitAsync(cancellationToken); - try - { - await inner.FlushAsync(cancellationToken); - } - finally - { - _ = _writeLock.Release(); - } - } - - public override void Flush() - { - if (!IsKeepAliveActive) - { - inner.Flush(); - return; - } - - throw new NotSupportedException("Synchronous flush is not supported on active SSE keepalive streams."); - } - - public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); - - public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => - inner.ReadAsync(buffer, offset, count, cancellationToken); - - public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => - inner.ReadAsync(buffer, cancellationToken); - - public override long Seek(long offset, SeekOrigin origin) => inner.Seek(offset, origin); - - public override void SetLength(long value) => inner.SetLength(value); - - public override bool CanRead => inner.CanRead; - - public override bool CanSeek => inner.CanSeek; - - public override bool CanWrite => inner.CanWrite; - - public override long Length => inner.Length; - - public override long Position - { - get => inner.Position; - set => inner.Position = value; - } - - public override async ValueTask DisposeAsync() - { - await StopKeepAlive(); - _writeLock.Dispose(); - - await base.DisposeAsync(); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - // Signal the background task to stop before disposing resources - var cts = Interlocked.Exchange(ref _cts, null); - if (cts is not null) - { - cts.Cancel(); - try - { - _keepAliveTask?.GetAwaiter().GetResult(); - } - catch (OperationCanceledException) - { - // Expected on cancellation - } - - _timer?.Dispose(); - cts.Dispose(); - } - - _writeLock.Dispose(); - } - - base.Dispose(disposing); - } -} From b05d62a382832dfac91afd224710fdaa39cb118d Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 15 Jun 2026 10:41:40 +0200 Subject: [PATCH 3/4] fix: disable resilience retry for unsafe HTTP methods POST/PUT/DELETE are not idempotent; retrying them can cause duplicate mutations. DisableForUnsafeHttpMethods() restricts the standard resilience retry handler to safe methods only (GET, HEAD, OPTIONS). Co-Authored-By: Claude Sonnet 4.6 --- .../AppDefaultsExtensions.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs index c0ae330572..701f62dd07 100644 --- a/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs +++ b/src/Elastic.Documentation.ServiceDefaults/AppDefaultsExtensions.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Http.Resilience; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Console; @@ -36,7 +37,10 @@ public static TBuilder AddDocumentationServiceDefaults( .AddElasticDocumentationLogging(cliOptions.LogLevel) .ConfigureHttpClientDefaults(http => { - _ = http.AddStandardResilienceHandler(); + _ = http.AddStandardResilienceHandler(options => + { + options.Retry.DisableForUnsafeHttpMethods(); + }); }) .AddConfigurationFileProvider(cliOptions.SkipPrivateRepositories, cliOptions.ConfigSource, (s, p) => { From c0aeb23331786454554f1a32b12467427abff052 Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 15 Jun 2026 10:54:03 +0200 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: Martijn Laarman --- aspire/Properties/launchSettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aspire/Properties/launchSettings.json b/aspire/Properties/launchSettings.json index c9a552a03b..474d114c7c 100644 --- a/aspire/Properties/launchSettings.json +++ b/aspire/Properties/launchSettings.json @@ -7,7 +7,7 @@ "launchBrowser": true, "applicationUrl": "https://localhost:17166;http://localhost:15066", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "dev", + "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21053", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22211"