From 1a96cd20dad97cb2a57ebf367028e6b251db7c87 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 24 Sep 2026 15:00:15 +0200 Subject: [PATCH] feat(web,backend): AI Integration settings page; one self-hosted client per tenant, raisable by a Global Admin - Settings > Tenant > AI Integration (between SLA Targets and Bootstrap Sessions) explains the normal connection first - server URL for Claude, ChatGPT, VS Code and other hosted assistants, nothing to register - and shows the self-hosted client registration below it (Tenant Admin, platform switch on). The page is visible to admins, platform scopes and anyone with MCP access. The self-hosted example text is neutral. - A tenant may hold one self-hosted client registration. The GA-only TenantConfiguration.McpClientRegistrationLimit (1-10) raises it on request: reverted for non-GA callers on PUT, refused on PATCH, edited in the Global Admin tenant dialog. MCP_SERVER_URL joins the portal URL registry. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal | 2 +- .../McpClientRegistrationTests.cs | 41 +++++-- .../TableStorage/TableConfigRepository.cs | 2 + .../Admin/McpClientRegistrationFunction.cs | 2 +- .../UpdateTenantConfigurationFunction.cs | 4 +- .../Helpers/TenantConfigValidation.cs | 4 + .../Services/McpClientRegistrationService.cs | 21 +++- .../Services/TenantConfigPatchService.cs | 1 + .../src/generated/wire-types.generated.ts | 2 + .../Models/Config/TenantConfiguration.cs | 8 ++ .../components/TenantManagementSection.tsx | 18 +++ .../sections/tenantConfigReportCatalog.ts | 1 + .../tenant/[section]/SectionClient.tsx | 4 +- ...edClients.tsx => SectionAiIntegration.tsx} | 111 +++++++++++------- .../app/settings/tenant/tenantNavSections.ts | 2 +- .../lib/__tests__/navVisibility.test.ts | 24 ++-- .../lib/globalNavConfig.tsx | 2 +- .../lib/navVisibility.ts | 7 +- src/Web/autopilot-monitor-web/utils/config.ts | 3 + .../utils/shared-manifests.generated.ts | 2 + .../utils/shared-manifests.json | 12 ++ .../utils/wire-types.generated.ts | 2 + 22 files changed, 205 insertions(+), 70 deletions(-) rename src/Web/autopilot-monitor-web/app/settings/tenant/sections/{SectionSelfHostedClients.tsx => SectionAiIntegration.tsx} (73%) diff --git a/internal b/internal index d07d299f..deb4fd0a 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit d07d299f36f9841810a394f742fd58ec0fad94e1 +Subproject commit deb4fd0ab2fbc0717b810c87460fa6f1dc687bce diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpClientRegistrationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpClientRegistrationTests.cs index 0c7223d0..513ebe49 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpClientRegistrationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpClientRegistrationTests.cs @@ -141,7 +141,7 @@ public class McpClientRegistrationTests public required Mock Audit { get; init; } } - private static Harness Build(bool enabled = true) + private static Harness Build(bool enabled = true, int? tenantLimit = null) { var rows = new List(); var repo = new Mock(); @@ -156,7 +156,11 @@ public class McpClientRegistrationTests var cache = new MemoryCache(new MemoryCacheOptions()); var adminConfig = new Mock(Mock.Of(), NullLogger.Instance, cache) { CallBase = false }; adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { McpClientRegistrationEnabled = enabled }); - var sut = new McpClientRegistrationService(repo.Object, audit.Object, adminConfig.Object, NullLogger.Instance); + var configRepo = new Mock(); + configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny())) + .ReturnsAsync((string t) => new TenantConfiguration { TenantId = t, McpClientRegistrationLimit = t == TenantA ? tenantLimit : null }); + var tenantConfig = new TenantConfigurationService(configRepo.Object, NullLogger.Instance, cache); + var sut = new McpClientRegistrationService(repo.Object, audit.Object, adminConfig.Object, tenantConfig, NullLogger.Instance); return new Harness { Sut = sut, Repo = repo, Audit = audit }; } @@ -189,23 +193,40 @@ public class McpClientRegistrationTests } [Fact] - public async Task Create_EnforcesTheCap_AndRefusesADuplicateCallback() + public async Task Create_AllowsOneRegistrationByDefault_PerTenant() { var h = Build(); + + Assert.Equal(HttpStatusCode.Created, (await h.Sut.CreateAsync(TenantA, "chat", Callback, "a")).Status); + var second = await h.Sut.CreateAsync(TenantA, "chat 2", "https://chat2.contoso.example/cb", "a"); + var otherTenant = await h.Sut.CreateAsync(TenantB, "chat", Callback, "b"); + + Assert.Equal(HttpStatusCode.Conflict, second.Status); + Assert.Contains("raise the limit", second.Error); + Assert.Equal(HttpStatusCode.Created, otherTenant.Status); // the limit and the duplicate check are per tenant + Assert.Equal(1, await h.Sut.GetLimitAsync(TenantA)); + } + + [Fact] + public async Task AGlobalAdminOverride_RaisesTheLimit_AndDuplicatesStayRefused() + { + var h = Build(tenantLimit: 3); + Assert.Equal(3, await h.Sut.GetLimitAsync(TenantA.ToUpperInvariant())); for (var i = 0; i < 3; i++) Assert.Equal(HttpStatusCode.Created, (await h.Sut.CreateAsync(TenantA, $"chat {i}", $"https://chat{i}.contoso.example/cb", "a")).Status); + Assert.Equal(HttpStatusCode.Conflict, (await h.Sut.CreateAsync(TenantA, "chat 4", "https://chat4.contoso.example/cb", "a")).Status); - var fourth = await h.Sut.CreateAsync(TenantA, "chat 4", "https://chat4.contoso.example/cb", "a"); - var otherTenant = await h.Sut.CreateAsync(TenantB, "chat", "https://chat0.contoso.example/cb", "b"); - - Assert.Equal(HttpStatusCode.Conflict, fourth.Status); - Assert.Equal(HttpStatusCode.Created, otherTenant.Status); // the cap and the duplicate check are per tenant - - var dup = Build(); + var dup = Build(tenantLimit: 3); await dup.Sut.CreateAsync(TenantA, "one", Callback, "a"); Assert.Equal(HttpStatusCode.Conflict, (await dup.Sut.CreateAsync(TenantA, "two", Callback.ToUpperInvariant(), "a")).Status); } + [Theory] + [InlineData(0, 1)] + [InlineData(50, 10)] + public async Task AnOutOfRangeOverride_IsClamped(int stored, int effective) + => Assert.Equal(effective, await Build(tenantLimit: stored).Sut.GetLimitAsync(TenantA)); + [Fact] public async Task Create_ValidatesBeforeStoring() { diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index f7b7dab7..20450c55 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -670,6 +670,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "DisabledUntil", config.DisabledUntil }, { "McpDisabled", config.McpDisabled }, { "McpDisabledReason", config.McpDisabledReason }, + { "McpClientRegistrationLimit", config.McpClientRegistrationLimit }, { "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute }, { "CustomUserRateLimitRequestsPerMinute", config.CustomUserRateLimitRequestsPerMinute }, { "ManufacturerWhitelist", config.ManufacturerWhitelist }, @@ -784,6 +785,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage DisabledUntil = entity.GetDateTime("DisabledUntil"), McpDisabled = entity.GetBoolean("McpDisabled") ?? false, McpDisabledReason = entity.GetString("McpDisabledReason"), + McpClientRegistrationLimit = entity.GetInt32("McpClientRegistrationLimit"), CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"), CustomUserRateLimitRequestsPerMinute = entity.GetInt32("CustomUserRateLimitRequestsPerMinute"), ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/McpClientRegistrationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/McpClientRegistrationFunction.cs index bfffeff3..e803abb4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/McpClientRegistrationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/McpClientRegistrationFunction.cs @@ -50,7 +50,7 @@ public class McpClientRegistrationFunction return await req.OkAsync(new McpClientRegistrationListResponse { Enabled = await _service.IsEnabledAsync(), - MaxRegistrations = McpClientRegistrationService.MaxRegistrationsPerTenant, + MaxRegistrations = await _service.GetLimitAsync(requestCtx.TargetTenantId), ServerUrl = $"{Constants.McpServerBaseUrl}/mcp", Registrations = registrations.Select(ToItem).ToList(), }); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs index 94ea1f30..f1a3dd2e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs @@ -128,7 +128,8 @@ namespace AutopilotMonitor.Functions.Functions.Config config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute || config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute || config.Disabled != existingConfig.Disabled || - config.McpDisabled != existingConfig.McpDisabled) + config.McpDisabled != existingConfig.McpDisabled || + config.McpClientRegistrationLimit != existingConfig.McpClientRegistrationLimit) { _logger.LogWarning( "Tenant Admin {User} attempted to modify GA-only fields for tenant {TenantId}", @@ -148,6 +149,7 @@ namespace AutopilotMonitor.Functions.Functions.Config config.DisabledReason = existingConfig.DisabledReason; config.DisabledUntil = existingConfig.DisabledUntil; config.McpDisabled = existingConfig.McpDisabled; + config.McpClientRegistrationLimit = existingConfig.McpClientRegistrationLimit; config.McpDisabledReason = existingConfig.McpDisabledReason; } diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs index bab59f1b..3314ec36 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs @@ -158,6 +158,10 @@ namespace AutopilotMonitor.Functions.Helpers if (customLimitError != null) return $"{customLimitError} override must be at least 1 request per minute (or left blank to inherit the global default)."; + if (candidate.McpClientRegistrationLimit is int registrations + && (registrations < 1 || registrations > Services.McpClientRegistrationService.MaxRegistrationLimit)) + return $"Self-hosted AI client registrations must be between 1 and {Services.McpClientRegistrationService.MaxRegistrationLimit} (or left blank for the default of {Services.McpClientRegistrationService.DefaultRegistrationLimit})."; + var contactEmailError = ValidateContactEmail(candidate.ContactEmail); if (contactEmailError != null) return $"Invalid contact email: {contactEmailError}"; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpClientRegistrationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpClientRegistrationService.cs index 3623c460..29771bd0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/McpClientRegistrationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpClientRegistrationService.cs @@ -13,7 +13,10 @@ namespace AutopilotMonitor.Functions.Services; /// public class McpClientRegistrationService { - public const int MaxRegistrationsPerTenant = 3; + /// Registrations a tenant may hold unless a Global Admin raised its limit on request. + public const int DefaultRegistrationLimit = 1; + /// Upper bound of the per-tenant override (TenantConfiguration.McpClientRegistrationLimit). + public const int MaxRegistrationLimit = 10; public const int MaxNameLength = 64; /// Mirrors the MCP proxy's redirect_uri length limit (oauth-limits.ts). public const int MaxRedirectUriLength = 1024; @@ -25,20 +28,30 @@ public class McpClientRegistrationService private readonly IMcpClientRegistrationRepository _repo; private readonly IMaintenanceRepository _maintenanceRepo; private readonly AdminConfigurationService _adminConfigService; + private readonly TenantConfigurationService _tenantConfigService; private readonly ILogger _logger; public McpClientRegistrationService( IMcpClientRegistrationRepository repo, IMaintenanceRepository maintenanceRepo, AdminConfigurationService adminConfigService, + TenantConfigurationService tenantConfigService, ILogger logger) { _repo = repo; _maintenanceRepo = maintenanceRepo; _adminConfigService = adminConfigService; + _tenantConfigService = tenantConfigService; _logger = logger; } + /// The tenant's registration limit: its Global-Admin override, else the default of one. + public virtual async Task GetLimitAsync(string tenantId) + { + var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId.ToLowerInvariant()); + return config.McpClientRegistrationLimit is int limit && limit >= 1 ? Math.Min(limit, MaxRegistrationLimit) : DefaultRegistrationLimit; + } + /// The operator switch (5-minute cached admin configuration). public virtual async Task IsEnabledAsync() => (await _adminConfigService.GetConfigurationAsync()).McpClientRegistrationEnabled; @@ -95,8 +108,10 @@ public class McpClientRegistrationService var tenant = tenantId.ToLowerInvariant(); var callback = redirectUri!.Trim(); var existing = await _repo.GetForTenantAsync(tenant); - if (existing.Count >= MaxRegistrationsPerTenant) - return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict, $"A tenant can register at most {MaxRegistrationsPerTenant} self-hosted clients. Delete one first."); + var limit = await GetLimitAsync(tenant); + if (existing.Count >= limit) + return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict, + $"This tenant can register {limit} self-hosted AI client{(limit == 1 ? "" : "s")}. Delete one first, or ask us to raise the limit."); if (existing.Any(r => string.Equals(r.RedirectUri, callback, StringComparison.OrdinalIgnoreCase))) return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict, "This callback URL is already registered."); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index b807fced..e59a2e5a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -148,6 +148,7 @@ namespace AutopilotMonitor.Functions.Services "EnableEspContinueAnywayObservation", "CustomRateLimitRequestsPerMinute", "CustomUserRateLimitRequestsPerMinute", "MaxNdjsonPayloadSizeMB", + "McpClientRegistrationLimit", }; /// diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index 3c06a92b..d71926fb 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -4743,6 +4743,8 @@ export interface TenantConfiguration { mcpDisabled: boolean; /** Optional reason why MCP access was disabled. Shown to the caller in the 403 response. */ mcpDisabledReason?: string | null; + /** How many self-hosted MCP client registrations this tenant may hold. Null = the platform default (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction GA gate, TenantConfigPatchService.GaOnlyFields). */ + mcpClientRegistrationLimit?: number | null; /** Optional per-tenant override for the device (agent/cert) API rate limit. If null, the effective limit is the global AdminConfiguration.GlobalRateLimitRequestsPerMinute. If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate). */ customRateLimitRequestsPerMinute?: number | null; /** Optional per-tenant override for the user (portal/JWT) API rate limit applied to standard users (Tenant Admins, Operators, Viewers). If null, the effective limit is the global AdminConfiguration.UserRateLimitRequestsPerMinute. Global-Admin-only. Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute (cross-tenant), so this override does not apply to them. */ diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index d53e1d4d..a41db41a 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -155,6 +155,13 @@ namespace AutopilotMonitor.Shared.Models /// public string? McpDisabledReason { get; set; } + /// + /// How many self-hosted MCP client registrations this tenant may hold. Null = the platform default + /// (one); set by a Global Admin on request, 1-10. Global-Admin-only (UpdateTenantConfigurationFunction + /// GA gate, TenantConfigPatchService.GaOnlyFields). + /// + public int? McpClientRegistrationLimit { get; set; } = null; + // ===== SECURITY SETTINGS ===== /// @@ -1084,6 +1091,7 @@ namespace AutopilotMonitor.Shared.Models DisabledUntil = null, McpDisabled = false, McpDisabledReason = null, + McpClientRegistrationLimit = null, CustomRateLimitRequestsPerMinute = null, CustomUserRateLimitRequestsPerMinute = null, ManufacturerWhitelist = "Dell*,HP*,Lenovo*,Microsoft Corporation", diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index dc3212c0..dbf4304b 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -1185,6 +1185,24 @@ function TenantManagementSectionInner({

Per-user limit for MCP and integration requests made on behalf of this tenant's standard users. Portal sessions use the global portal budget and are not affected. Leave blank to inherit the global default. Does not apply to Global Admins.

+ {/* Self-hosted AI client registrations (raised on request) */} +
+ + { + const v = e.target.value.trim(); + setEditingTenant({ ...editingTenant, mcpClientRegistrationLimit: v === "" ? null : (parseInt(v) || null) }); + }} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors" + /> +

How many self-hosted AI clients this tenant may register under Settings → Tenant → AI Integration (1–10). Leave blank for the default of one; raise it on request.

+
+