Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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"]
64 changes: 62 additions & 2 deletions Web/Resgrid.Web.Mcp/McpServerHost.cs
Original file line number Diff line number Diff line change
@@ -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
{
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,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();
}
}
}

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