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 new file mode 100644 index 00000000..355f6ff7 --- /dev/null +++ b/Web/Resgrid.Web.Mcp/Controllers/HealthController.cs @@ -0,0 +1,107 @@ +using System; +using System.Net.Http; +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; + private readonly IHttpClientFactory _httpClientFactory; + + public HealthController( + McpToolRegistry toolRegistry, + IResponseCache responseCache, + IHttpClientFactory httpClientFactory) + { + _toolRegistry = toolRegistry; + _responseCache = responseCache; + _httpClientFactory = httpClientFactory; + } + + /// + /// Gets the current health status of the MCP Server + /// + /// HealthResult object with the server health status + [HttpGet("current")] + public async Task 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 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 + { + 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 + { + return false; + } + } + + private async Task ProbeApiConnectivityAsync() + { + try + { + var apiBaseUrl = SystemBehaviorConfig.ResgridApiBaseUrl; + 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 + { + return false; + } + } + } +} + + diff --git a/Web/Resgrid.Web.Mcp/Dockerfile b/Web/Resgrid.Web.Mcp/Dockerfile index 1f843531..df1f98d8 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,5 @@ RUN chmod +x wait WORKDIR /app COPY --from=publish /app/publish . + 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..6ac76414 100644 --- a/Web/Resgrid.Web.Mcp/McpServerHost.cs +++ b/Web/Resgrid.Web.Mcp/McpServerHost.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; 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,57 @@ 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"); + var transactionFinished = false; + 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); + transactionFinished = true; + _applicationLifetime.StopApplication(); + return; + } + finally + { + if (!transactionFinished) + { + transaction.Finish(); + } } } 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..9bd32bc9 100644 --- a/Web/Resgrid.Web.Mcp/Program.cs +++ b/Web/Resgrid.Web.Mcp/Program.cs @@ -1,76 +1,130 @@ using System; using System.IO; -using System.Threading.Tasks; +using System.Reflection; 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 { 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()) - .ConfigureAppConfiguration((hostingContext, config) => - { - config.SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) - .AddEnvironmentVariables() - .AddCommandLine(args); - }) - .ConfigureLogging((hostingContext, logging) => + .ConfigureLogging(logging => { logging.ClearProviders(); logging.AddConsole(); - logging.SetMinimumLevel(LogLevel.Information); }) - .ConfigureServices((hostContext, services) => + .ConfigureWebHostDefaults(webBuilder => { - var configuration = hostContext.Configuration; + 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()); - // Load Resgrid configuration - bool configResult = ConfigProcessor.LoadAndProcessConfig(configuration["AppOptions:ConfigPath"]); - if (!configResult) + if (!string.IsNullOrWhiteSpace(ExternalErrorConfig.ExternalErrorServiceUrlForMcp)) { - throw new InvalidOperationException( - $"Failed to load configuration from path: {configuration["AppOptions:ConfigPath"] ?? "default path"}. " + - "Ensure the configuration file exists and is valid."); + 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(); + options.ProfilesSampleRate = ExternalErrorConfig.SentryProfilingSampleRate; + + // 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) + )); + + options.TracesSampler = samplingContext => + { + if (samplingContext != null && samplingContext.CustomSamplingContext != null) + { + if (samplingContext.CustomSamplingContext.TryGetValue("__HttpPath", out var httpPath)) + { + var pathValue = httpPath?.ToString(); + if (string.Equals(pathValue, "/health/getcurrent", StringComparison.OrdinalIgnoreCase)) + { + return 0; + } + } + } + + return ExternalErrorConfig.SentryPerfSampleRate; + }; + }); } - bool envConfigResult = ConfigProcessor.LoadAndProcessEnvVariables(configuration.AsEnumerable()); - if (!envConfigResult) + webBuilder.UseKestrel(serverOptions => { - Console.WriteLine("Warning: No environment variables were loaded. This may be expected if not using environment-based configuration."); - } + // Configure Kestrel to listen on a specific port for health checks + serverOptions.ListenAnyIP(5050); // Health check port + }); + webBuilder.Configure(app => + { + app.UseRouting(); + app.UseSentryTracing(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + }); + }) + .ConfigureAppConfiguration((_, config) => + { + config.SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables() + .AddCommandLine(args); + }) + .ConfigureServices((hostContext, services) => + { + var configuration = hostContext.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 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..5a571333 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 @@ -13,13 +13,6 @@ DOCKER - - - PreserveNewest - true - PreserveNewest - - @@ -32,6 +25,7 @@ + @@ -39,6 +33,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": "" }