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
117 changes: 15 additions & 102 deletions Web/Resgrid.Web.Mcp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,131 +44,44 @@ public static IHostBuilder CreateHostBuilder(string[] args) =>
{
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.Release = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all GetEntryAssembly() usages without null-conditional operators
rg -n 'GetEntryAssembly\(\)\.' --type cs -A2 -B2

Repository: Resgrid/Core

Length of output: 3757


🏁 Script executed:

#!/bin/bash
# Check specifically for the Logging.cs file mentioned in the review
fd -e cs -p 'Logging.cs' | head -20

Repository: Resgrid/Core

Length of output: 89


Good defensive null-safety improvement, but multiple other locations need the same fix.

Using null-conditional operators on GetEntryAssembly() and Version prevents potential NullReferenceException in hosted environments where GetEntryAssembly() can return null. The change in Program.cs follows C# best practices.

However, the codebase has multiple other unsafe usages of this same pattern:

  • Web/Resgrid.Web/Program.cs line 59
  • Web/Resgrid.Web.Services/Program.cs line 58
  • Web/Resgrid.Web.Eventing/Program.cs line 57
  • Workers/Resgrid.Workers.Console/Program.cs line 103
  • Web/Resgrid.Web/Controllers/HealthController.cs line 35
  • Web/Resgrid.Web.Services/Controllers/v4/HealthController.cs line 42
  • Web/Resgrid.Web.Eventing/Controllers/HealthController.cs line 37
  • Core/Resgrid.Framework/Logging.cs line 40

Apply the same null-conditional operator pattern to all these locations for consistency and safety.

🤖 Prompt for AI Agents
In `@Web/Resgrid.Web.Mcp/Program.cs` at line 53, The assignment using
Assembly.GetEntryAssembly() and Version should use null-conditional operators
like the change in Program.cs; update the same pattern wherever
GetEntryAssembly()?.GetName().Version?.ToString() is needed — specifically
adjust the Release assignment in the Program classes (symbols: options.Release
in Resgrid.Web/Program.cs, Resgrid.Web.Services/Program.cs,
Resgrid.Web.Eventing/Program.cs, Resgrid.Workers.Console/Program.cs) and the
health/version exposures in HealthController classes (symbols: HealthController
in Resgrid.Web.Controllers, Resgrid.Web.Services.Controllers.v4,
Resgrid.Web.Eventing.Controllers) as well as the logging version read in
Resgrid.Framework.Logging (symbol: Logging class); replace direct
Assembly.GetEntryAssembly().GetName().Version.ToString() calls with
null-conditional variants and handle a null result safely (e.g., assign null or
empty string) to avoid NullReferenceException.

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)
));
// Add profiling integration
options.AddIntegration(new ProfilingIntegration());

options.TracesSampler = samplingContext =>
{
if (samplingContext != null && samplingContext.CustomSamplingContext != null)
options.TracesSampler = samplingContext =>
{
if (samplingContext.CustomSamplingContext.TryGetValue("__HttpPath", out var httpPath))
if (samplingContext?.CustomSamplingContext != null)
{
var pathValue = httpPath?.ToString();
if (string.Equals(pathValue, "/health/getcurrent", StringComparison.OrdinalIgnoreCase))
if (samplingContext.CustomSamplingContext.TryGetValue("__HttpPath", out var httpPath))
{
return 0;
var pathValue = httpPath?.ToString();
if (string.Equals(pathValue, "/health/getcurrent", StringComparison.OrdinalIgnoreCase))
{
return 0;
}
}
}
}

return ExternalErrorConfig.SentryPerfSampleRate;
};
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(app =>
{
app.UseRouting();
app.UseSentryTracing();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
serverOptions.ListenAnyIP(5050);
});
})
.ConfigureAppConfiguration((_, config) =>
{
config.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.AddEnvironmentVariables()
.AddCommandLine(args);
})
.ConfigureServices((hostContext, services) =>
{
var configuration = hostContext.Configuration;

// Configuration is already loaded in ConfigureWebHostDefaults
// Initialize Resgrid logging framework with Sentry if available
if (!string.IsNullOrWhiteSpace(ExternalErrorConfig.ExternalErrorServiceUrlForMcp))
{
Framework.Logging.Initialize(ExternalErrorConfig.ExternalErrorServiceUrlForMcp);
}

// Register MCP server
services.AddHostedService<McpServerHost>();

// Add MVC controllers for health check endpoint
services.AddControllers()
.AddNewtonsoftJson();

// Register infrastructure services
services.AddMemoryCache();
services.AddSingleton<Infrastructure.IResponseCache, Infrastructure.ResponseCache>();
services.AddSingleton<Infrastructure.IRateLimiter, Infrastructure.RateLimiter>();
services.AddSingleton<Infrastructure.ITokenRefreshService, Infrastructure.TokenRefreshService>();
services.AddSingleton<Infrastructure.IAuditLogger, Infrastructure.AuditLogger>();

// Validate required API configuration from SystemBehaviorConfig
if (string.IsNullOrWhiteSpace(SystemBehaviorConfig.ResgridApiBaseUrl))
{
throw new InvalidOperationException(
"SystemBehaviorConfig.ResgridApiBaseUrl is required but not configured. " +
"Configure this setting via the Resgrid configuration file or environment variables (RESGRID:SystemBehaviorConfig:ResgridApiBaseUrl).");
}

// Register HTTP client for API calls with connection pooling
services.AddHttpClient("ResgridApi", client =>
{
client.BaseAddress = new Uri(SystemBehaviorConfig.ResgridApiBaseUrl);
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 10
});

// Register API client
services.AddSingleton<IApiClient, ApiClient>();

// Register tool providers
services.AddSingleton<Tools.AuthenticationToolProvider>();
services.AddSingleton<Tools.CallsToolProvider>();
services.AddSingleton<Tools.DispatchToolProvider>();
services.AddSingleton<Tools.PersonnelToolProvider>();
services.AddSingleton<Tools.UnitsToolProvider>();
services.AddSingleton<Tools.MessagesToolProvider>();
services.AddSingleton<Tools.CalendarToolProvider>();
services.AddSingleton<Tools.ShiftsToolProvider>();
services.AddSingleton<Tools.InventoryToolProvider>();
services.AddSingleton<Tools.ReportsToolProvider>();
services.AddSingleton<McpToolRegistry>();
webBuilder.UseStartup<Startup>();
});
}
}
Expand Down
94 changes: 94 additions & 0 deletions Web/Resgrid.Web.Mcp/Startup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Resgrid.Config;
using Resgrid.Web.Mcp.Infrastructure;
using Resgrid.Web.Mcp.Tools;

namespace Resgrid.Web.Mcp
{
public class Startup
{
public IConfiguration Configuration { get; }

public Startup(IConfiguration configuration)
{
Configuration = configuration;
}

public void ConfigureServices(IServiceCollection services)
{
// Initialize Resgrid logging framework with Sentry if available
if (!string.IsNullOrWhiteSpace(ExternalErrorConfig.ExternalErrorServiceUrlForMcp))
{
Framework.Logging.Initialize(ExternalErrorConfig.ExternalErrorServiceUrlForMcp);
}

// Register MCP server
services.AddHostedService<McpServerHost>();

// Add MVC controllers for health check endpoint
services.AddControllers()
.AddNewtonsoftJson();

// Register infrastructure services
services.AddMemoryCache();
services.AddSingleton<IResponseCache, ResponseCache>();
services.AddSingleton<IRateLimiter, RateLimiter>();
services.AddSingleton<ITokenRefreshService, TokenRefreshService>();
services.AddSingleton<IAuditLogger, AuditLogger>();
Comment on lines +36 to +41
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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.cs" | xargs rg -l 'class ResponseCache|class RateLimiter|class TokenRefreshService|class AuditLogger' | head -20

Repository: Resgrid/Core

Length of output: 273


🏁 Script executed:

# Find and examine these implementations
rg -B 2 -A 20 'class ResponseCache\s*[:\(]' --type cs

Repository: Resgrid/Core

Length of output: 1912


🏁 Script executed:

rg -B 2 -A 20 'class RateLimiter\s*[:\(]' --type cs

Repository: Resgrid/Core

Length of output: 1998


🏁 Script executed:

rg -B 2 -A 20 'class TokenRefreshService\s*[:\(]' --type cs

Repository: Resgrid/Core

Length of output: 2199


🏁 Script executed:

rg -B 2 -A 20 'class AuditLogger\s*[:\(]' --type cs

Repository: Resgrid/Core

Length of output: 1756


🏁 Script executed:

cat -n Web/Resgrid.Web.Mcp/Infrastructure/RateLimiter.cs

Repository: Resgrid/Core

Length of output: 5735


🏁 Script executed:

cat -n Web/Resgrid.Web.Mcp/Infrastructure/ResponseCache.cs

Repository: Resgrid/Core

Length of output: 2181


🏁 Script executed:

cat -n Web/Resgrid.Web.Mcp/Infrastructure/TokenRefreshService.cs

Repository: Resgrid/Core

Length of output: 5217


🏁 Script executed:

cat -n Web/Resgrid.Web.Mcp/Infrastructure/AuditLogger.cs

Repository: Resgrid/Core

Length of output: 6643


RateLimiter and ResponseCache are already properly thread-safe, but TokenRefreshService has a race condition.

RateLimiter uses ConcurrentDictionary with explicit locking in RequestCounter.TryAddIfUnderLimit (lines 138-156); ResponseCache delegates to IMemoryCache, which is thread-safe by design. However, TokenRefreshService.GetValidTokenAsync has a time-of-check-time-of-use race: two concurrent requests can both read an expired/missing token (line 36), both decide to refresh (lines 50-54), and both issue redundant token refresh calls. Use ConcurrentDictionary.AddOrUpdate with a factory method or add async coordination to prevent duplicate refresh attempts.

🤖 Prompt for AI Agents
In `@Web/Resgrid.Web.Mcp/Startup.cs` around lines 36 - 41,
TokenRefreshService.GetValidTokenAsync has a TOCTOU race where concurrent
callers both see a missing/expired token and perform duplicate refreshes; change
GetValidTokenAsync to atomically coordinate refreshes (e.g., use a
ConcurrentDictionary<string, Lazy<Task<Token>>> or
ConcurrentDictionary.AddOrUpdate with a factory that returns a single Task to
perform refresh) so only the first caller triggers the refresh and others await
that Task; ensure you replace the current token-read/refresh branches in
TokenRefreshService.GetValidTokenAsync with the AddOrUpdate/Lazy<Task> pattern,
correctly await the stored Task, and handle/remap exceptions and token storage
on completion.


// Validate required API configuration from SystemBehaviorConfig
if (string.IsNullOrWhiteSpace(SystemBehaviorConfig.ResgridApiBaseUrl))
{
throw new InvalidOperationException(
"SystemBehaviorConfig.ResgridApiBaseUrl is required but not configured. " +
"Configure this setting via the Resgrid configuration file or environment variables (RESGRID:SystemBehaviorConfig:ResgridApiBaseUrl).");
}

// Register HTTP client for API calls with connection pooling
services.AddHttpClient("ResgridApi", client =>
{
client.BaseAddress = new Uri(SystemBehaviorConfig.ResgridApiBaseUrl);
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 10
});

// Register API client
services.AddSingleton<IApiClient, ApiClient>();

// Register tool providers
services.AddSingleton<AuthenticationToolProvider>();
services.AddSingleton<CallsToolProvider>();
services.AddSingleton<DispatchToolProvider>();
services.AddSingleton<PersonnelToolProvider>();
services.AddSingleton<UnitsToolProvider>();
services.AddSingleton<MessagesToolProvider>();
services.AddSingleton<CalendarToolProvider>();
services.AddSingleton<ShiftsToolProvider>();
services.AddSingleton<InventoryToolProvider>();
services.AddSingleton<ReportsToolProvider>();
services.AddSingleton<McpToolRegistry>();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();

app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
}
}


Loading