From 17ad944996b3b0a9434d137f00ad82559488d423 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 12 Feb 2026 18:13:42 -0800 Subject: [PATCH 1/5] RE1-T102 Added healthcheck --- .../Controllers/HealthController.cs | 72 +++++++++++++++++++ Web/Resgrid.Web.Mcp/Dockerfile | 6 ++ Web/Resgrid.Web.Mcp/Models/HealthResult.cs | 44 ++++++++++++ Web/Resgrid.Web.Mcp/Program.cs | 26 ++++++- Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj | 3 +- 5 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 Web/Resgrid.Web.Mcp/Controllers/HealthController.cs create mode 100644 Web/Resgrid.Web.Mcp/Models/HealthResult.cs diff --git a/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs b/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs new file mode 100644 index 00000000..e957d0b4 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs @@ -0,0 +1,72 @@ +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Config; +using Resgrid.Web.Mcp.Infrastructure; +using Resgrid.Web.Mcp.Models; + +namespace Resgrid.Web.Mcp.Controllers +{ + /// + /// Health Check system to get information and health status of the MCP Server + /// + [AllowAnonymous] + [Route("health")] + public sealed class HealthController : Controller + { + private readonly McpToolRegistry _toolRegistry; + private readonly IResponseCache _responseCache; + + public HealthController( + McpToolRegistry toolRegistry, + IResponseCache responseCache) + { + _toolRegistry = toolRegistry; + _responseCache = responseCache; + } + + /// + /// Gets the current health status of the MCP Server + /// + /// HealthResult object with the server health status + [HttpGet("current")] + public IActionResult GetCurrent() + { + var result = new HealthResult + { + ServerVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "Unknown", + ServerName = McpConfig.ServerName ?? "Resgrid MCP Server", + SiteId = "0", + ToolCount = _toolRegistry.GetToolCount(), + ServerRunning = true + }; + + // Check cache connectivity + try + { + result.CacheOnline = _responseCache != null; + } + catch + { + result.CacheOnline = false; + } + + // Check API connectivity + try + { + // Simple ping to the API to check connectivity + var apiBaseUrl = SystemBehaviorConfig.ResgridApiBaseUrl; + result.ApiOnline = !string.IsNullOrWhiteSpace(apiBaseUrl); + } + catch + { + result.ApiOnline = false; + } + + return Json(result); + } + } +} + + diff --git a/Web/Resgrid.Web.Mcp/Dockerfile b/Web/Resgrid.Web.Mcp/Dockerfile index 1f843531..dd0cefe5 100644 --- a/Web/Resgrid.Web.Mcp/Dockerfile +++ b/Web/Resgrid.Web.Mcp/Dockerfile @@ -6,6 +6,7 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0.3-noble-amd64 AS base ARG BUILD_VERSION WORKDIR /app EXPOSE 80 +EXPOSE 5050 FROM mcr.microsoft.com/dotnet/sdk:9.0.202-noble-amd64 AS build ARG BUILD_VERSION @@ -43,4 +44,9 @@ RUN chmod +x wait WORKDIR /app COPY --from=publish /app/publish . + +# Health check configuration +HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \ + CMD curl -f http://localhost:5050/health/current || exit 1 + ENTRYPOINT ["sh", "-c", "./wait && dotnet Resgrid.Web.Mcp.dll"] diff --git a/Web/Resgrid.Web.Mcp/Models/HealthResult.cs b/Web/Resgrid.Web.Mcp/Models/HealthResult.cs new file mode 100644 index 00000000..16be8942 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Models/HealthResult.cs @@ -0,0 +1,44 @@ +namespace Resgrid.Web.Mcp.Models +{ + /// + /// Response for getting the health of the Resgrid MCP Server. + /// + public sealed class HealthResult + { + /// + /// Site\Location of this MCP Server + /// + public string SiteId { get; set; } + + /// + /// The Version of the MCP Server + /// + public string ServerVersion { get; set; } + + /// + /// The name of the MCP Server + /// + public string ServerName { get; set; } + + /// + /// Number of registered tools + /// + public int ToolCount { get; set; } + + /// + /// Can the MCP Server talk to the Resgrid API + /// + public bool ApiOnline { get; set; } + + /// + /// Can the MCP Server talk to the cache + /// + public bool CacheOnline { get; set; } + + /// + /// Is the MCP Server running + /// + public bool ServerRunning { get; set; } + } +} + diff --git a/Web/Resgrid.Web.Mcp/Program.cs b/Web/Resgrid.Web.Mcp/Program.cs index aa09e870..c0021196 100644 --- a/Web/Resgrid.Web.Mcp/Program.cs +++ b/Web/Resgrid.Web.Mcp/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Threading.Tasks; using Autofac.Extensions.DependencyInjection; @@ -31,14 +31,30 @@ public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .UseServiceProviderFactory(new AutofacServiceProviderFactory()) .UseContentRoot(Directory.GetCurrentDirectory()) - .ConfigureAppConfiguration((hostingContext, config) => + .ConfigureWebHostDefaults(webBuilder => + { + webBuilder.UseKestrel(serverOptions => + { + // Configure Kestrel to listen on a specific port for health checks + serverOptions.ListenAnyIP(5050); // Health check port + }); + webBuilder.Configure((context, app) => + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + }); + }) + .ConfigureAppConfiguration((_, config) => { config.SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) .AddEnvironmentVariables() .AddCommandLine(args); }) - .ConfigureLogging((hostingContext, logging) => + .ConfigureLogging((_, logging) => { logging.ClearProviders(); logging.AddConsole(); @@ -71,6 +87,10 @@ public static IHostBuilder CreateHostBuilder(string[] args) => // Register MCP server services.AddHostedService(); + // Add MVC controllers for health check endpoint + services.AddControllers() + .AddNewtonsoftJson(); + // Register infrastructure services services.AddMemoryCache(); services.AddSingleton(); diff --git a/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj index c7b19e35..6d3ab6b5 100644 --- a/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj +++ b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj @@ -1,4 +1,4 @@ - + Model Context Protocol (MCP) Server for Resgrid CAD System 1.0.0 @@ -32,6 +32,7 @@ + From 13401ae0c661c52c227b7c70c59bc01f5bc941e9 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 13 Feb 2026 12:20:18 -0800 Subject: [PATCH 2/5] RE1-T102 MCP server fixesx --- Core/Resgrid.Config/ExternalErrorConfig.cs | 3 +- .../Controllers/HealthController.cs | 59 +++++++++++--- Web/Resgrid.Web.Mcp/Dockerfile | 4 - Web/Resgrid.Web.Mcp/McpServerHost.cs | 57 +++++++++++++- Web/Resgrid.Web.Mcp/Program.cs | 76 ++++++++++++++----- Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj | 4 +- .../Tools/CallsToolProvider.cs | 43 +++++++++++ Web/Resgrid.Web.Mcp/appsettings.json | 11 ++- 8 files changed, 217 insertions(+), 40 deletions(-) diff --git a/Core/Resgrid.Config/ExternalErrorConfig.cs b/Core/Resgrid.Config/ExternalErrorConfig.cs index dbb229c7..17c4b152 100644 --- a/Core/Resgrid.Config/ExternalErrorConfig.cs +++ b/Core/Resgrid.Config/ExternalErrorConfig.cs @@ -1,4 +1,4 @@ -namespace Resgrid.Config +namespace Resgrid.Config { /// /// Configuration for working with external error tracking systems like Elk and Sentry @@ -21,6 +21,7 @@ public static class ExternalErrorConfig public static string ExternalErrorServiceUrlForEventing = ""; public static string ExternalErrorServiceUrlForInternalApi = ""; public static string ExternalErrorServiceUrlForInternalWorker = ""; + public static string ExternalErrorServiceUrlForMcp = ""; public static double SentryPerfSampleRate = 0.4; public static double SentryProfilingSampleRate = 0; #endregion Sentry Settings diff --git a/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs b/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs index e957d0b4..355f6ff7 100644 --- a/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs +++ b/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs @@ -1,4 +1,6 @@ -using System.Reflection; +using System; +using System.Net.Http; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -17,13 +19,16 @@ public sealed class HealthController : Controller { private readonly McpToolRegistry _toolRegistry; private readonly IResponseCache _responseCache; + private readonly IHttpClientFactory _httpClientFactory; public HealthController( McpToolRegistry toolRegistry, - IResponseCache responseCache) + IResponseCache responseCache, + IHttpClientFactory httpClientFactory) { _toolRegistry = toolRegistry; _responseCache = responseCache; + _httpClientFactory = httpClientFactory; } /// @@ -31,7 +36,7 @@ public HealthController( /// /// HealthResult object with the server health status [HttpGet("current")] - public IActionResult GetCurrent() + public async Task GetCurrent() { var result = new HealthResult { @@ -42,29 +47,59 @@ public IActionResult GetCurrent() ServerRunning = true }; - // Check cache connectivity + // Check cache connectivity with real probe + result.CacheOnline = await ProbeCacheConnectivityAsync(); + + // Check API connectivity with real probe + result.ApiOnline = await ProbeApiConnectivityAsync(); + + return Json(result); + } + + private async Task ProbeCacheConnectivityAsync() + { try { - result.CacheOnline = _responseCache != null; + const string sentinelKey = "_healthcheck_sentinel"; + var sentinelValue = Guid.NewGuid().ToString(); + var ttl = TimeSpan.FromSeconds(5); + + // Attempt to set and retrieve a sentinel value + var retrieved = await _responseCache.GetOrCreateAsync( + sentinelKey, + () => Task.FromResult(sentinelValue), + ttl); + + // Verify the value matches and clean up + var success = retrieved == sentinelValue; + _responseCache.Remove(sentinelKey); + + return success; } catch { - result.CacheOnline = false; + return false; } + } - // Check API connectivity + private async Task ProbeApiConnectivityAsync() + { try { - // Simple ping to the API to check connectivity var apiBaseUrl = SystemBehaviorConfig.ResgridApiBaseUrl; - result.ApiOnline = !string.IsNullOrWhiteSpace(apiBaseUrl); + if (string.IsNullOrWhiteSpace(apiBaseUrl)) + return false; + + using var httpClient = _httpClientFactory.CreateClient("ResgridApi"); + using var request = new HttpRequestMessage(HttpMethod.Head, "/"); + using var response = await httpClient.SendAsync(request); + + return response.IsSuccessStatusCode; } catch { - result.ApiOnline = false; + return false; } - - return Json(result); } } } diff --git a/Web/Resgrid.Web.Mcp/Dockerfile b/Web/Resgrid.Web.Mcp/Dockerfile index dd0cefe5..df1f98d8 100644 --- a/Web/Resgrid.Web.Mcp/Dockerfile +++ b/Web/Resgrid.Web.Mcp/Dockerfile @@ -45,8 +45,4 @@ RUN chmod +x wait WORKDIR /app COPY --from=publish /app/publish . -# Health check configuration -HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \ - CMD curl -f http://localhost:5050/health/current || exit 1 - ENTRYPOINT ["sh", "-c", "./wait && dotnet Resgrid.Web.Mcp.dll"] diff --git a/Web/Resgrid.Web.Mcp/McpServerHost.cs b/Web/Resgrid.Web.Mcp/McpServerHost.cs index c4af5cb5..f67d5f96 100644 --- a/Web/Resgrid.Web.Mcp/McpServerHost.cs +++ b/Web/Resgrid.Web.Mcp/McpServerHost.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using Resgrid.Config; +using Sentry; namespace Resgrid.Web.Mcp { @@ -35,6 +36,9 @@ public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("Starting Resgrid MCP Server..."); + // Add Sentry breadcrumb for startup + SentrySdk.AddBreadcrumb("MCP Server starting", "server.lifecycle", level: BreadcrumbLevel.Info); + _stoppingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); try @@ -49,7 +53,20 @@ public Task StartAsync(CancellationToken cancellationToken) // Register all tools from the registry _toolRegistry.RegisterTools(_mcpServer); - _logger.LogInformation("MCP Server initialized with {ToolCount} tools", _toolRegistry.GetToolCount()); + var toolCount = _toolRegistry.GetToolCount(); + _logger.LogInformation("MCP Server initialized with {ToolCount} tools", toolCount); + + // Add Sentry breadcrumb for successful initialization + SentrySdk.AddBreadcrumb( + $"MCP Server initialized with {toolCount} tools", + "server.lifecycle", + data: new System.Collections.Generic.Dictionary + { + { "server_name", serverName }, + { "server_version", serverVersion }, + { "tool_count", toolCount.ToString() } + }, + level: BreadcrumbLevel.Info); // Start the server execution _executingTask = ExecuteAsync(_stoppingCts.Token); @@ -60,10 +77,20 @@ public Task StartAsync(CancellationToken cancellationToken) } _logger.LogInformation("Resgrid MCP Server started successfully"); + SentrySdk.AddBreadcrumb("MCP Server started successfully", "server.lifecycle", level: BreadcrumbLevel.Info); } catch (Exception ex) { _logger.LogError(ex, "Failed to start MCP Server"); + + // Capture exception in Sentry + SentrySdk.CaptureException(ex, scope => + { + scope.SetTag("component", "McpServerHost"); + scope.SetTag("operation", "StartAsync"); + scope.Level = SentryLevel.Fatal; + }); + throw; } @@ -73,6 +100,7 @@ public Task StartAsync(CancellationToken cancellationToken) public async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("Stopping Resgrid MCP Server..."); + SentrySdk.AddBreadcrumb("MCP Server stopping", "server.lifecycle", level: BreadcrumbLevel.Info); if (_executingTask == null) { @@ -92,25 +120,52 @@ public async Task StopAsync(CancellationToken cancellationToken) } _logger.LogInformation("Resgrid MCP Server stopped"); + SentrySdk.AddBreadcrumb("MCP Server stopped", "server.lifecycle", level: BreadcrumbLevel.Info); } private async Task ExecuteAsync(CancellationToken stoppingToken) { + // Start a Sentry transaction for the MCP server execution + var transaction = SentrySdk.StartTransaction("mcp.server.execution", "mcp.lifecycle"); + try { _logger.LogInformation("MCP Server listening on stdio transport..."); + SentrySdk.AddBreadcrumb("MCP Server started listening", "server.lifecycle", level: BreadcrumbLevel.Info); // Run the server - this will handle stdio communication await _mcpServer.RunAsync(stoppingToken); + + transaction.Status = SpanStatus.Ok; } catch (OperationCanceledException) { _logger.LogInformation("MCP Server execution was cancelled"); + SentrySdk.AddBreadcrumb("MCP Server execution cancelled", "server.lifecycle", level: BreadcrumbLevel.Info); + transaction.Status = SpanStatus.Cancelled; } catch (Exception ex) { _logger.LogError(ex, "Unexpected error in MCP Server execution"); + + // Capture exception in Sentry with context + SentrySdk.CaptureException(ex, scope => + { + scope.SetTag("component", "McpServerHost"); + scope.SetTag("operation", "ExecuteAsync"); + scope.Level = SentryLevel.Fatal; + scope.AddBreadcrumb("Server execution failed", "server.error", level: BreadcrumbLevel.Error); + }); + + transaction.Status = SpanStatus.InternalError; + transaction.Finish(ex); + _applicationLifetime.StopApplication(); + return; + } + finally + { + transaction.Finish(); } } diff --git a/Web/Resgrid.Web.Mcp/Program.cs b/Web/Resgrid.Web.Mcp/Program.cs index c0021196..3de549ac 100644 --- a/Web/Resgrid.Web.Mcp/Program.cs +++ b/Web/Resgrid.Web.Mcp/Program.cs @@ -1,12 +1,16 @@ -using System; +using System; using System.IO; +using System.Reflection; using System.Threading.Tasks; using Autofac.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Resgrid.Config; +using Sentry.Profiling; namespace Resgrid.Web.Mcp { @@ -33,12 +37,59 @@ public static IHostBuilder CreateHostBuilder(string[] args) => .UseContentRoot(Directory.GetCurrentDirectory()) .ConfigureWebHostDefaults(webBuilder => { + // Load configuration first + var builder = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables(); + var config = builder.Build(); + + bool configResult = ConfigProcessor.LoadAndProcessConfig(config["AppOptions:ConfigPath"]); + bool envConfigResult = ConfigProcessor.LoadAndProcessEnvVariables(config.AsEnumerable()); + + // Configure Sentry if DSN is provided + if (!string.IsNullOrWhiteSpace(ExternalErrorConfig.ExternalErrorServiceUrlForMcp)) + { + webBuilder.UseSentry(options => + { + options.Dsn = ExternalErrorConfig.ExternalErrorServiceUrlForMcp; + options.AttachStacktrace = true; + options.SendDefaultPii = true; + options.AutoSessionTracking = true; + options.TracesSampleRate = ExternalErrorConfig.SentryPerfSampleRate; + options.Environment = ExternalErrorConfig.Environment; + options.Release = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"; + options.ProfilesSampleRate = ExternalErrorConfig.SentryProfilingSampleRate; + + // Add profiling integration + options.AddIntegration(new ProfilingIntegration()); + + // Custom trace sampling to exclude health check endpoints + options.TracesSampler = samplingContext => + { + if (samplingContext?.CustomSamplingContext != null && + samplingContext.CustomSamplingContext.ContainsKey("__HttpPath")) + { + var path = samplingContext.CustomSamplingContext["__HttpPath"]?.ToString()?.ToLower(); + if (path == "/health/getcurrent" || + path == "/health" || + path == "/api/health/getcurrent") + { + return 0; // Don't sample health checks + } + } + + return ExternalErrorConfig.SentryPerfSampleRate; + }; + }); + } + webBuilder.UseKestrel(serverOptions => { // Configure Kestrel to listen on a specific port for health checks serverOptions.ListenAnyIP(5050); // Health check port }); - webBuilder.Configure((context, app) => + webBuilder.Configure(app => { app.UseRouting(); app.UseEndpoints(endpoints => @@ -64,24 +115,11 @@ public static IHostBuilder CreateHostBuilder(string[] args) => { var configuration = hostContext.Configuration; - // Load Resgrid configuration - bool configResult = ConfigProcessor.LoadAndProcessConfig(configuration["AppOptions:ConfigPath"]); - if (!configResult) - { - throw new InvalidOperationException( - $"Failed to load configuration from path: {configuration["AppOptions:ConfigPath"] ?? "default path"}. " + - "Ensure the configuration file exists and is valid."); - } - - bool envConfigResult = ConfigProcessor.LoadAndProcessEnvVariables(configuration.AsEnumerable()); - if (!envConfigResult) - { - Console.WriteLine("Warning: No environment variables were loaded. This may be expected if not using environment-based configuration."); - } - - if (!string.IsNullOrWhiteSpace(ExternalErrorConfig.ExternalErrorServiceUrlForApi)) + // Configuration is already loaded in ConfigureWebHostDefaults + // Initialize Resgrid logging framework with Sentry if available + if (!string.IsNullOrWhiteSpace(ExternalErrorConfig.ExternalErrorServiceUrlForMcp)) { - Framework.Logging.Initialize(ExternalErrorConfig.ExternalErrorServiceUrlForApi); + Framework.Logging.Initialize(ExternalErrorConfig.ExternalErrorServiceUrlForMcp); } // Register MCP server diff --git a/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj index 6d3ab6b5..fe51e247 100644 --- a/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj +++ b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj @@ -1,4 +1,4 @@ - + Model Context Protocol (MCP) Server for Resgrid CAD System 1.0.0 @@ -40,6 +40,8 @@ + + diff --git a/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs b/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs index 952f7625..cc849ea1 100644 --- a/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs +++ b/Web/Resgrid.Web.Mcp/Tools/CallsToolProvider.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging; using ModelContextProtocol.Server; using Newtonsoft.Json; +using Sentry; namespace Resgrid.Web.Mcp.Tools { @@ -56,22 +57,30 @@ private void RegisterGetActiveCallsTool(McpServer server) schema, async (arguments) => { + var transaction = SentrySdk.StartTransaction("mcp.tool.get_active_calls", "mcp.tool"); + try { var args = JsonConvert.DeserializeObject(arguments.ToString()); if (string.IsNullOrWhiteSpace(args?.AccessToken)) { + transaction.Status = SpanStatus.InvalidArgument; + transaction.Finish(); return CreateErrorResponse("Access token is required"); } _logger.LogInformation("Retrieving active calls"); + SentrySdk.AddBreadcrumb("Retrieving active calls", "mcp.tool", level: BreadcrumbLevel.Info); var result = await _apiClient.GetAsync( "/api/v4/Calls/GetActiveCalls", args.AccessToken ); + transaction.Status = SpanStatus.Ok; + SentrySdk.AddBreadcrumb("Active calls retrieved successfully", "mcp.tool", level: BreadcrumbLevel.Info); + return new { success = true, @@ -81,8 +90,19 @@ private void RegisterGetActiveCallsTool(McpServer server) catch (Exception ex) { _logger.LogError(ex, "Error retrieving active calls"); + transaction.Status = SpanStatus.InternalError; + SentrySdk.CaptureException(ex, scope => + { + scope.SetTag("tool", "get_active_calls"); + scope.SetTag("operation", "retrieve_active_calls"); + }); + return CreateErrorResponse("Failed to retrieve active calls. Please try again later."); } + finally + { + transaction.Finish(); + } } ); } @@ -107,27 +127,39 @@ private void RegisterGetCallDetailsTool(McpServer server) schema, async (arguments) => { + var transaction = SentrySdk.StartTransaction("mcp.tool.get_call_details", "mcp.tool"); + try { var args = JsonConvert.DeserializeObject(arguments.ToString()); if (string.IsNullOrWhiteSpace(args?.AccessToken)) { + transaction.Status = SpanStatus.InvalidArgument; + transaction.Finish(); return CreateErrorResponse("Access token is required"); } if (args.CallId <= 0) { + transaction.Status = SpanStatus.InvalidArgument; + transaction.Finish(); return CreateErrorResponse("Valid call ID is required"); } _logger.LogInformation("Retrieving call details for call {CallId}", args.CallId); + SentrySdk.AddBreadcrumb($"Retrieving call details for call {args.CallId}", "mcp.tool", + data: new Dictionary { { "call_id", args.CallId.ToString() } }, + level: BreadcrumbLevel.Info); var result = await _apiClient.GetAsync( $"/api/v4/Calls/GetCall?callId={args.CallId}", args.AccessToken ); + transaction.Status = SpanStatus.Ok; + SentrySdk.AddBreadcrumb("Call details retrieved successfully", "mcp.tool", level: BreadcrumbLevel.Info); + return new { success = true, @@ -137,8 +169,19 @@ private void RegisterGetCallDetailsTool(McpServer server) catch (Exception ex) { _logger.LogError(ex, "Error retrieving call details"); + transaction.Status = SpanStatus.InternalError; + SentrySdk.CaptureException(ex, scope => + { + scope.SetTag("tool", "get_call_details"); + scope.SetTag("operation", "retrieve_call_details"); + }); + return CreateErrorResponse("Failed to retrieve call details. Please try again later."); } + finally + { + transaction.Finish(); + } } ); } diff --git a/Web/Resgrid.Web.Mcp/appsettings.json b/Web/Resgrid.Web.Mcp/appsettings.json index a73f576d..98af3808 100644 --- a/Web/Resgrid.Web.Mcp/appsettings.json +++ b/Web/Resgrid.Web.Mcp/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "Logging": { "LogLevel": { "Default": "Information", @@ -6,10 +6,17 @@ "Microsoft.Hosting.Lifetime": "Information" } }, + "Sentry": { + "IncludeActivityData": true, + "MaxBreadcrumbs": 50, + "MinimumBreadcrumbLevel": "Information", + "MinimumEventLevel": "Error", + "Debug": false + }, "AppOptions": { "ConfigPath": "" }, - "_Comment": "MCP Server configuration is now managed by Resgrid.Config classes. API Base URL uses SystemBehaviorConfig.ResgridApiBaseUrl. Configure via ResgridConfig.json or environment variables (RESGRID:SystemBehaviorConfig:ResgridApiBaseUrl, RESGRID:McpConfig:ServerName, RESGRID:McpConfig:ServerVersion, RESGRID:McpConfig:Transport)", + "_Comment": "MCP Server configuration is now managed by Resgrid.Config classes. API Base URL uses SystemBehaviorConfig.ResgridApiBaseUrl. Sentry DSN uses ExternalErrorConfig.ExternalErrorServiceUrlForMcp. Configure via ResgridConfig.json or environment variables (RESGRID:SystemBehaviorConfig:ResgridApiBaseUrl, RESGRID:ExternalErrorConfig:ExternalErrorServiceUrlForMcp, RESGRID:McpConfig:ServerName, RESGRID:McpConfig:ServerVersion, RESGRID:McpConfig:Transport)", "ConnectionStrings": { "ResgridContext": "" } From 56f84d11c3ab09f7225086c9268445ab19d6a60d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 13 Feb 2026 14:01:35 -0800 Subject: [PATCH 3/5] RE1-T102 Updating MCP startup to use Config --- Web/Resgrid.Web.Mcp/Program.cs | 58 +++++++++++++++------------------- 1 file changed, 25 insertions(+), 33 deletions(-) diff --git a/Web/Resgrid.Web.Mcp/Program.cs b/Web/Resgrid.Web.Mcp/Program.cs index 3de549ac..7b58f221 100644 --- a/Web/Resgrid.Web.Mcp/Program.cs +++ b/Web/Resgrid.Web.Mcp/Program.cs @@ -1,7 +1,6 @@ -using System; +using System; using System.IO; using System.Reflection; -using System.Threading.Tasks; using Autofac.Extensions.DependencyInjection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -16,28 +15,22 @@ namespace Resgrid.Web.Mcp { public static class Program { - public static async Task Main(string[] args) + public static void Main(string[] args) { - try - { - var host = CreateHostBuilder(args).Build(); - await host.RunAsync(); - return 0; - } - catch (Exception ex) - { - Console.Error.WriteLine($"Fatal error: {ex}"); - return 1; - } + CreateHostBuilder(args).Build().Run(); } public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .UseServiceProviderFactory(new AutofacServiceProviderFactory()) .UseContentRoot(Directory.GetCurrentDirectory()) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }) .ConfigureWebHostDefaults(webBuilder => { - // Load configuration first var builder = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) @@ -47,35 +40,40 @@ public static IHostBuilder CreateHostBuilder(string[] args) => bool configResult = ConfigProcessor.LoadAndProcessConfig(config["AppOptions:ConfigPath"]); bool envConfigResult = ConfigProcessor.LoadAndProcessEnvVariables(config.AsEnumerable()); - // Configure Sentry if DSN is provided if (!string.IsNullOrWhiteSpace(ExternalErrorConfig.ExternalErrorServiceUrlForMcp)) { webBuilder.UseSentry(options => { + //options.MinimumBreadcrumbLevel = LogEventLevel.Debug; + //options.MinimumEventLevel = LogEventLevel.Error; options.Dsn = ExternalErrorConfig.ExternalErrorServiceUrlForMcp; options.AttachStacktrace = true; options.SendDefaultPii = true; options.AutoSessionTracking = true; + + //if (ExternalErrorConfig.SentryPerfSampleRate > 0) + // options.EnableTracing = true; + options.TracesSampleRate = ExternalErrorConfig.SentryPerfSampleRate; options.Environment = ExternalErrorConfig.Environment; - options.Release = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown"; + options.Release = Assembly.GetEntryAssembly().GetName().Version.ToString(); options.ProfilesSampleRate = ExternalErrorConfig.SentryProfilingSampleRate; - // Add profiling integration - options.AddIntegration(new ProfilingIntegration()); + // Requires NuGet package: Sentry.Profiling + // Note: By default, the profiler is initialized asynchronously. This can be tuned by passing a desired initialization timeout to the constructor. + options.AddIntegration(new ProfilingIntegration( + // During startup, wait up to 500ms to profile the app startup code. This could make launching the app a bit slower so comment it out if your prefer profiling to start asynchronously + //TimeSpan.FromMilliseconds(500) + )); - // Custom trace sampling to exclude health check endpoints options.TracesSampler = samplingContext => { - if (samplingContext?.CustomSamplingContext != null && - samplingContext.CustomSamplingContext.ContainsKey("__HttpPath")) + if (samplingContext != null && samplingContext.CustomSamplingContext != null) { - var path = samplingContext.CustomSamplingContext["__HttpPath"]?.ToString()?.ToLower(); - if (path == "/health/getcurrent" || - path == "/health" || - path == "/api/health/getcurrent") + if (samplingContext.CustomSamplingContext.ContainsKey("__HttpPath") && + samplingContext.CustomSamplingContext["__HttpPath"]?.ToString().ToLower() == "/health/getcurrent") { - return 0; // Don't sample health checks + return 0; } } @@ -105,12 +103,6 @@ public static IHostBuilder CreateHostBuilder(string[] args) => .AddEnvironmentVariables() .AddCommandLine(args); }) - .ConfigureLogging((_, logging) => - { - logging.ClearProviders(); - logging.AddConsole(); - logging.SetMinimumLevel(LogLevel.Information); - }) .ConfigureServices((hostContext, services) => { var configuration = hostContext.Configuration; From 7c2ac7f7c45349885659d132d64ed4f739c4a944 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 13 Feb 2026 14:57:28 -0800 Subject: [PATCH 4/5] RE1-T102 PR#279 fixes --- Web/Resgrid.Web.Mcp/McpServerHost.cs | 9 +++++++-- Web/Resgrid.Web.Mcp/Program.cs | 18 +++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Web/Resgrid.Web.Mcp/McpServerHost.cs b/Web/Resgrid.Web.Mcp/McpServerHost.cs index f67d5f96..6ac76414 100644 --- a/Web/Resgrid.Web.Mcp/McpServerHost.cs +++ b/Web/Resgrid.Web.Mcp/McpServerHost.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; @@ -127,6 +127,7 @@ private async Task ExecuteAsync(CancellationToken stoppingToken) { // Start a Sentry transaction for the MCP server execution var transaction = SentrySdk.StartTransaction("mcp.server.execution", "mcp.lifecycle"); + var transactionFinished = false; try { @@ -159,13 +160,17 @@ private async Task ExecuteAsync(CancellationToken stoppingToken) transaction.Status = SpanStatus.InternalError; transaction.Finish(ex); + transactionFinished = true; _applicationLifetime.StopApplication(); return; } finally { - transaction.Finish(); + if (!transactionFinished) + { + transaction.Finish(); + } } } diff --git a/Web/Resgrid.Web.Mcp/Program.cs b/Web/Resgrid.Web.Mcp/Program.cs index 7b58f221..9bd32bc9 100644 --- a/Web/Resgrid.Web.Mcp/Program.cs +++ b/Web/Resgrid.Web.Mcp/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Reflection; using Autofac.Extensions.DependencyInjection; @@ -66,19 +66,22 @@ public static IHostBuilder CreateHostBuilder(string[] args) => //TimeSpan.FromMilliseconds(500) )); - options.TracesSampler = samplingContext => + options.TracesSampler = samplingContext => + { + if (samplingContext != null && samplingContext.CustomSamplingContext != null) { - if (samplingContext != null && samplingContext.CustomSamplingContext != null) + if (samplingContext.CustomSamplingContext.TryGetValue("__HttpPath", out var httpPath)) { - if (samplingContext.CustomSamplingContext.ContainsKey("__HttpPath") && - samplingContext.CustomSamplingContext["__HttpPath"]?.ToString().ToLower() == "/health/getcurrent") + var pathValue = httpPath?.ToString(); + if (string.Equals(pathValue, "/health/getcurrent", StringComparison.OrdinalIgnoreCase)) { return 0; } } + } - return ExternalErrorConfig.SentryPerfSampleRate; - }; + return ExternalErrorConfig.SentryPerfSampleRate; + }; }); } @@ -90,6 +93,7 @@ public static IHostBuilder CreateHostBuilder(string[] args) => webBuilder.Configure(app => { app.UseRouting(); + app.UseSentryTracing(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); From f4e24f845fb49da6e9e39c8c7b085cbff6dda5ae Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 13 Feb 2026 15:07:22 -0800 Subject: [PATCH 5/5] RE1-T102 PR#279 fixes --- Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj index fe51e247..5a571333 100644 --- a/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj +++ b/Web/Resgrid.Web.Mcp/Resgrid.Web.Mcp.csproj @@ -13,13 +13,6 @@ DOCKER - - - PreserveNewest - true - PreserveNewest - -