Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
3 changes: 2 additions & 1 deletion Core/Resgrid.Config/ExternalErrorConfig.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace Resgrid.Config
namespace Resgrid.Config
{
/// <summary>
/// Configuration for working with external error tracking systems like Elk and Sentry
Expand All @@ -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
Expand Down
107 changes: 107 additions & 0 deletions Web/Resgrid.Web.Mcp/Controllers/HealthController.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Health Check system to get information and health status of the MCP Server
/// </summary>
[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;
}

/// <summary>
/// Gets the current health status of the MCP Server
/// </summary>
/// <returns>HealthResult object with the server health status</returns>
[HttpGet("current")]
public async Task<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 with real probe
result.CacheOnline = await ProbeCacheConnectivityAsync();

// Check API connectivity with real probe
result.ApiOnline = await ProbeApiConnectivityAsync();

return Json(result);
}
Comment on lines +38 to +57
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Route mismatch: TracesSampler excludes wrong paths for this endpoint.

The actual health endpoint route is /health/current (Line 17 [Route("health")] + Line 38 [HttpGet("current")]). However, in Program.cs Lines 74-76, the TracesSampler checks for /health/getcurrent and /api/health/getcurrent — neither matches the real route. Health check probes will be sampled and sent to Sentry, adding noise.

Fix the paths in Program.cs:

-									if (path == "/health/getcurrent" ||
-									    path == "/health" ||
-									    path == "/api/health/getcurrent")
+									if (path == "/health/current" ||
+									    path == "/health" ||
+									    path?.StartsWith("/health/") == true)
🤖 Prompt for AI Agents
In `@Web/Resgrid.Web.Mcp/Controllers/HealthController.cs` around lines 38 - 57,
The TracesSampler in Program.cs is excluding the wrong paths for the health
endpoint — it checks for "/health/getcurrent" and "/api/health/getcurrent" while
the actual action is HealthController.GetCurrent exposed at "/health/current";
update the TracesSampler checks to exclude "/health/current" and
"/api/health/current" (and consider making the match case-insensitive and
tolerant of a trailing slash) so real health probes are sampled out and not sent
to Sentry.


private async Task<bool> 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<bool> 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;
}
}
}
}


2 changes: 2 additions & 0 deletions Web/Resgrid.Web.Mcp/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,4 +44,5 @@ RUN chmod +x wait

WORKDIR /app
COPY --from=publish /app/publish .

ENTRYPOINT ["sh", "-c", "./wait && dotnet Resgrid.Web.Mcp.dll"]
57 changes: 56 additions & 1 deletion Web/Resgrid.Web.Mcp/McpServerHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Server;
using Resgrid.Config;
using Sentry;

namespace Resgrid.Web.Mcp
{
Expand Down Expand Up @@ -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
Expand All @@ -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<string, string>
{
{ "server_name", serverName },
{ "server_version", serverVersion },
{ "tool_count", toolCount.ToString() }
},
level: BreadcrumbLevel.Info);

// Start the server execution
_executingTask = ExecuteAsync(_stoppingCts.Token);
Expand All @@ -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;
}

Expand All @@ -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)
{
Expand All @@ -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();
}
}

Expand Down
44 changes: 44 additions & 0 deletions Web/Resgrid.Web.Mcp/Models/HealthResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace Resgrid.Web.Mcp.Models
{
/// <summary>
/// Response for getting the health of the Resgrid MCP Server.
/// </summary>
public sealed class HealthResult
{
/// <summary>
/// Site\Location of this MCP Server
/// </summary>
public string SiteId { get; set; }

/// <summary>
/// The Version of the MCP Server
/// </summary>
public string ServerVersion { get; set; }

/// <summary>
/// The name of the MCP Server
/// </summary>
public string ServerName { get; set; }

/// <summary>
/// Number of registered tools
/// </summary>
public int ToolCount { get; set; }

/// <summary>
/// Can the MCP Server talk to the Resgrid API
/// </summary>
public bool ApiOnline { get; set; }

/// <summary>
/// Can the MCP Server talk to the cache
/// </summary>
public bool CacheOnline { get; set; }

/// <summary>
/// Is the MCP Server running
/// </summary>
public bool ServerRunning { get; set; }
}
}

Loading
Loading