diff --git a/docs b/docs index 9100388f..e586726c 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 9100388fa6def5f2f309245a3057ea149e2b4433 +Subproject commit e586726c9ad6fe38a49e0c46cb4d3380699b46fc diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs index 8df9a610..fb343eef 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs @@ -430,7 +430,9 @@ public class ConfigWireParityTests effectiveEdition = "pro", retentionGraceEndsUtc, maxDelegatedTenants = 5, - maxDelegatedTenantsOverride = 5 + maxDelegatedTenantsOverride = 5, + mcpUsagePlan = "msp", + mcpUsagePlanOverride = "msp" }, new SetTenantPlanTierResponse { @@ -441,7 +443,9 @@ public class ConfigWireParityTests EffectiveEdition = "pro", RetentionGraceEndsUtc = retentionGraceEndsUtc, MaxDelegatedTenants = 5, - MaxDelegatedTenantsOverride = 5 + MaxDelegatedTenantsOverride = 5, + McpUsagePlan = "msp", + McpUsagePlanOverride = "msp" }); } @@ -452,6 +456,7 @@ public class ConfigWireParityTests DateTime? trialExpiresUtc = null; DateTime? retentionGraceEndsUtc = null; int? maxDelegatedTenantsOverride = null; + string? mcpUsagePlanOverride = null; AssertParity( new @@ -463,7 +468,9 @@ public class ConfigWireParityTests effectiveEdition = "community", retentionGraceEndsUtc, maxDelegatedTenants = 0, - maxDelegatedTenantsOverride + maxDelegatedTenantsOverride, + mcpUsagePlan = "community", + mcpUsagePlanOverride }, new SetTenantPlanTierResponse { @@ -474,7 +481,9 @@ public class ConfigWireParityTests EffectiveEdition = "community", RetentionGraceEndsUtc = null, MaxDelegatedTenants = 0, - MaxDelegatedTenantsOverride = null + MaxDelegatedTenantsOverride = null, + McpUsagePlan = "community", + McpUsagePlanOverride = null }); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs index 00843af0..ca4bc82f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs @@ -231,6 +231,19 @@ public class GetTenantFeatureFlagsPayloadTests .GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32()); } + [Fact] + public void Payload_Entitlements_McpUsagePlan_TenantOverrideWins_EditionUnchanged() + { + // The tenant-wide plan override changes the plan NAME only — the edition (and every Pro gate) stays. + var element = Serialize(new TenantConfiguration { PlanTier = "pro", McpUsagePlanOverride = " MSP " }); + Assert.Equal("msp", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString()); + Assert.Equal("pro", element.GetProperty("edition").GetString()); + + var community = Serialize(new TenantConfiguration { McpUsagePlanOverride = "msp" }); + Assert.Equal("msp", community.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString()); + Assert.Equal("community", community.GetProperty("edition").GetString()); + } + [Theory] [InlineData("pro")] [InlineData("enterprise")] // legacy stored value resolves — and reports — as "pro" diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs index 2db8e76b..fa06da4f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs @@ -661,7 +661,7 @@ public class InfrastructureWireParityTests limit = 200, used = 200L, resetUtc = "2026-08-31T00:00:00Z", - message = "MCP daily request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z." + message = "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-08-31T00:00:00Z." }, McpQuotaEnforcementMiddleware.BuildExceededResponse(decision)); } @@ -744,7 +744,7 @@ public class InfrastructureWireParityTests limit = 300, used = 300L, resetUtc = "2026-08-31T00:00:00Z", - message = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.", + message = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.", targetTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002" }, McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, "customer.example")); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaEnforcementMiddlewareTests.cs index dba496ea..2523e5b5 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaEnforcementMiddlewareTests.cs @@ -229,6 +229,48 @@ public class McpQuotaEnforcementMiddlewareTests Assert.DoesNotContain("managed tenant", body.Message); } + [Fact] + public void OwnCommunityTenant_NamesTheUpgradePath() + { + // The quota is the upgrade lever: a member blocked by their own Community organization window is told + // that Community is sized for occasional use and that Pro lifts it — Pro members never see the hint. + var community = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null)); + Assert.Contains("sized for occasional use", community.Message); + Assert.Contains("upgrading your organization to Pro", community.Message); + Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", community.Message); + + var pro = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", target: null, 3000, 3000)); + Assert.DoesNotContain("upgrading", pro.Message); + Assert.DoesNotContain("occasional use", pro.Message); + } + + [Fact] + public void ManagedCommunityTarget_SaysWhosePlanGoverns() + { + // The delegated admin must learn that the CUSTOMER's plan (not their own Pro/MSP plan) governs the window. + var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), "customer.example"); + Assert.Contains("on the Community plan", body.Message); + Assert.Contains("its own plan governs this window, not yours", body.Message); + Assert.Contains("Upgrading that tenant to Pro", body.Message); + } + + [Theory] + [InlineData("community", true)] + [InlineData("Community", true)] + [InlineData("pro", false)] + [InlineData("power", false)] // per-user override plan: an individual budget, "upgrade to Pro" would be wrong advice + public void UserLevelBlock_HintsProOnlyForTheCommunityEdition(string userPlan, bool expectHint) + { + var decision = McpQuotaService.BuildDecision( + new McpPlanLimits(userPlan, 100, 3000, "pro", 3000, 60000), + dailyUsed: 100, monthlyUsed: 500, tenantDailyUsed: 1, tenantMonthlyUsed: 1, Now); + var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(decision); + Assert.Equal("user", body.Level); + Assert.StartsWith($"MCP daily request quota exceeded for plan '{userPlan}'.", body.Message); + Assert.Equal(expectHint, body.Message.Contains("Pro raises your daily and monthly windows")); + Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Message); + } + [Fact] public void AllManagedTenantsExhausted_NamesTheCount_AndNoSingleTarget() { @@ -249,6 +291,8 @@ public class McpQuotaEnforcementMiddlewareTests var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, "customer.example", exhaustedTenantCount: 2); Assert.Equal("user", body.Level); Assert.Null(body.TargetTenantId); - Assert.Equal("MCP daily request quota exceeded for plan 'community'. Resets at 2026-09-03T00:00:00Z.", body.Message); + Assert.Equal( + "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-09-03T00:00:00Z.", + body.Message); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs index 776b40aa..94e0bcd5 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs @@ -126,7 +126,8 @@ public class McpQuotaServiceTests TenantEdition edition = TenantEdition.Community, IMemoryCache? cache = null, bool bound = true, - Func? editionResolver = null) + Func? editionResolver = null, + Func? tenantPlanOverrideResolver = null) { var adminRepo = new Mock(); adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny())) @@ -153,7 +154,7 @@ public class McpQuotaServiceTests usageRepo.Object, mcpUserService, adminConfigService, - new StubTenantEntitlementService(editionResolver ?? (_ => edition)), + new StubTenantEntitlementService(editionResolver ?? (_ => edition), tenantPlanOverrideResolver), cache, NullLogger.Instance, new TestTimeProvider(Now)); @@ -278,6 +279,86 @@ public class McpQuotaServiceTests Assert.Equal(100, d.DailyLimit); } + // ── Tenant-wide plan override (TenantConfiguration.McpUsagePlanOverride) ───── + // Precedence: per-user override > tenant override > edition default. The tenant override drives BOTH + // the members' default user plan AND the organization windows; the edition itself is untouched. + + private const string MspPlanJson = + """[{"name":"msp","dailyRequestLimit":1500,"monthlyRequestLimit":15000,"tenantDailyRequestLimit":5000,"tenantMonthlyRequestLimit":40000,"description":""}]"""; + + [Fact] + public async Task Check_TenantOverride_DrivesUserDefaultAndTenantWindows() + { + // Pro edition, tenant override "msp": user windows AND tenant windows follow the msp definition. + var svc = Build(UsageRepo(("20260707", 1200)), planDefinitionsJson: MspPlanJson, + edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp"); + + var d = await svc.CheckAsync(Oid, Upn, TenantId); + + Assert.True(d.Allowed); + Assert.Equal("msp", d.Plan); + Assert.Equal(1500, d.DailyLimit); + Assert.Equal(15000, d.MonthlyLimit); + Assert.Equal("msp", d.TenantPlan); + Assert.Equal(5000, d.TenantDailyLimit); + Assert.Equal(40000, d.TenantMonthlyLimit); + } + + [Fact] + public async Task Check_PerUserOverride_StillWinsOverTheTenantOverride_ForTheUserWindowsOnly() + { + var json = MspPlanJson.TrimEnd(']') + """,{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]"""; + var svc = Build(UsageRepo(("20260707", 2000)), planDefinitionsJson: json, + edition: TenantEdition.Pro, mcpUserPlanOverride: "power", tenantPlanOverrideResolver: _ => "msp"); + + var d = await svc.CheckAsync(Oid, Upn, TenantId); + + Assert.True(d.Allowed); + Assert.Equal("power", d.Plan); + Assert.Equal(10000, d.DailyLimit); + // The organization windows follow the TENANT plan (the override), never the per-user plan. + Assert.Equal("msp", d.TenantPlan); + Assert.Equal(5000, d.TenantDailyLimit); + } + + [Fact] + public async Task Check_TenantOverrideWithoutDefinition_FailsClosedToCommunityLimits() + { + // The plan endpoint validates the name on SET; if the definition is deleted later the read side + // falls back fail-closed (user: Community catalog; tenant: the EDITION's catalog windows). + var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp"); + + var d = await svc.CheckAsync(Oid, Upn, TenantId); + + Assert.False(d.Allowed); + Assert.Equal("msp", d.Plan); + Assert.Equal(100, d.DailyLimit); + Assert.Equal("msp", d.TenantPlan); + Assert.Equal(3000, d.TenantDailyLimit); + } + + [Fact] + public async Task Check_DelegatedTarget_MspHomeOverride_LiftsTheCallerNotTheCustomer() + { + // The MSP's home tenant carries the "msp" override; the managed Community customer does not. + // The caller's own windows are lifted, the customer's organization window still bites. + var repo = UsageRepo(("20260707", 1200)); + TenantRows(repo, Target, ("20260707", 300)); + var svc = Build(repo, planDefinitionsJson: MspPlanJson, + editionResolver: HomeProTargetsCommunity, + tenantPlanOverrideResolver: t => t == TenantId ? "msp" : null); + + var d = await svc.CheckAsync(Oid, Upn, homeTenantId: TenantId, chargeTenantId: Target); + + Assert.False(d.Allowed); + Assert.Equal("tenant", d.Level); + Assert.Equal("msp", d.Plan); + Assert.Equal(1500, d.DailyLimit); + Assert.Equal("community", d.TenantPlan); + Assert.Equal(300, d.TenantDailyLimit); + Assert.Equal(Target, d.TargetTenantId); + } + [Fact] public async Task Check_UsageLookupThrows_FailsOpen_AndDoesNotCache() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs index fdc9d65e..ac5e3c72 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs @@ -1,6 +1,7 @@ using AutopilotMonitor.Functions.Functions.Config; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Models.Config; namespace AutopilotMonitor.Functions.Tests; @@ -183,4 +184,64 @@ public class PlanManagementTransitionTests PlanManagementFunction.ApplyDelegatedSlotChange(config, provided: true, maxDelegatedTenants: 4, changes); Assert.Empty(changes); } + + // ── Tenant-wide MCP usage-plan override ────────────────────────────────────── + + [Fact] + public void McpPlanChange_NotProvided_IsANoOp() + { + var config = new TenantConfiguration { TenantId = "t1", McpUsagePlanOverride = "msp" }; + var changes = new Dictionary(); + PlanManagementFunction.ApplyMcpUsagePlanChange(config, provided: false, mcpUsagePlan: null, changes); + Assert.Equal("msp", config.McpUsagePlanOverride); + Assert.Empty(changes); + } + + [Fact] + public void McpPlanChange_SetFromDefault_NormalizesRecordsAndApplies() + { + var config = new TenantConfiguration { TenantId = "t1", PlanTier = "pro" }; + var changes = new Dictionary(); + PlanManagementFunction.ApplyMcpUsagePlanChange(config, provided: true, mcpUsagePlan: " MSP ", changes); + Assert.Equal("msp", config.McpUsagePlanOverride); + Assert.Equal("(edition default) -> msp", changes["McpUsagePlanOverride"]); + // The override never touches the edition. + Assert.Equal("pro", config.PlanTier); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void McpPlanChange_ClearToDefault_RecordsAndApplies(string? cleared) + { + var config = new TenantConfiguration { TenantId = "t1", McpUsagePlanOverride = "msp" }; + var changes = new Dictionary(); + PlanManagementFunction.ApplyMcpUsagePlanChange(config, provided: true, mcpUsagePlan: cleared, changes); + Assert.Null(config.McpUsagePlanOverride); + Assert.Equal("msp -> (edition default)", changes["McpUsagePlanOverride"]); + } + + [Fact] + public void McpPlanChange_SameValue_RecordsNothing() + { + var config = new TenantConfiguration { TenantId = "t1", McpUsagePlanOverride = "msp" }; + var changes = new Dictionary(); + PlanManagementFunction.ApplyMcpUsagePlanChange(config, provided: true, mcpUsagePlan: "Msp", changes); + Assert.Equal("msp", config.McpUsagePlanOverride); + Assert.Empty(changes); + } + + [Fact] + public void IsKnownUsagePlan_MatchesDefinitionsCaseInsensitively() + { + var definitions = new List + { + new() { Name = "community" }, new() { Name = "pro" }, new() { Name = " MSP " }, + }; + Assert.True(PlanManagementFunction.IsKnownUsagePlan("msp", definitions)); + Assert.True(PlanManagementFunction.IsKnownUsagePlan("pro", definitions)); + Assert.False(PlanManagementFunction.IsKnownUsagePlan("enterprise", definitions)); + Assert.False(PlanManagementFunction.IsKnownUsagePlan("msp", new List())); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs index bc4d37f5..29831d4f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs @@ -12,15 +12,22 @@ namespace AutopilotMonitor.Functions.Tests; internal sealed class StubTenantEntitlementService : TenantEntitlementService { private readonly Func _resolver; + private readonly Func _planOverrideResolver; public StubTenantEntitlementService(TenantEdition edition) : this(_ => edition) { } - public StubTenantEntitlementService(Func resolver) + /// Edition per tenant id. + /// + /// The tenant-wide MCP usage-plan override (TenantConfiguration.McpUsagePlanOverride) per tenant id; + /// null = no override (the edition's plan name applies). Defaults to "no override anywhere". + /// + public StubTenantEntitlementService(Func resolver, Func? planOverrideResolver = null) : base(configService: null!, logger: NullLogger.Instance) { _resolver = resolver; + _planOverrideResolver = planOverrideResolver ?? (_ => null); } public override Task GetEditionAsync(string? tenantId) @@ -28,4 +35,9 @@ internal sealed class StubTenantEntitlementService : TenantEntitlementService public override Task GetEntitlementsAsync(string? tenantId) => Task.FromResult(FeatureEntitlementCatalog.Get(_resolver(tenantId))); + + public override Task GetMcpUsagePlanNameAsync(string? tenantId) + => Task.FromResult( + NormalizePlanName(_planOverrideResolver(tenantId)) + ?? FeatureEntitlementCatalog.Get(_resolver(tenantId)).McpUsagePlanName); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs index c9adc7d2..e3b1c4a0 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs @@ -28,6 +28,7 @@ public class TenantConfigProjectionTests private static readonly string[] AllSafeKeys = { "tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed", "maxDelegatedTenantsOverride", + "mcpUsagePlanOverride", "disabled", "disabledReason", "onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays", "homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince", diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs index 8f6af755..b07385e5 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs @@ -225,4 +225,16 @@ public class TenantConfigTableSerializationTests Assert.Equal(overrideLimit, mapped.MaxDelegatedTenantsOverride); } + + [Theory] + [InlineData(null)] + [InlineData("msp")] + public void Roundtrip_McpUsagePlanOverride_SurvivesStoreAndMap(string? planOverride) + { + var config = new TenantConfiguration { TenantId = TenantId, DomainName = "contoso.com", UpdatedBy = "ga@operator.example", McpUsagePlanOverride = planOverride }; + + var mapped = TableConfigRepository.ConvertFromTenantTableEntity(TableConfigRepository.ConvertToTenantTableEntity(config)); + + Assert.Equal(planOverride, mapped.McpUsagePlanOverride); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs index 87e0d311..ca6a0d3b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs @@ -331,4 +331,58 @@ public class TenantEntitlementServiceTests var (svc, _) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "pro", MaxDelegatedTenantsOverride = 7 }); Assert.Equal(7, await svc.GetMaxDelegatedTenantsAsync(TenantId)); } + + // ── MCP usage plan (tenant-wide override) ──────────────────────────────────── + + [Theory] + [InlineData("pro", null, "pro")] + [InlineData("community", null, "community")] + [InlineData("free", null, "community")] + [InlineData("pro", "msp", "msp")] + [InlineData("community", "msp", "msp")] // override applies regardless of edition + [InlineData("pro", " MSP ", "msp")] // normalized like SectionUsagePlans names + [InlineData("pro", " ", "pro")] // blank = not set + public void GetMcpUsagePlanName_OverrideBeatsTheEditionDefault(string tier, string? planOverride, string expected) + { + var config = new TenantConfiguration { TenantId = TenantId, PlanTier = tier, McpUsagePlanOverride = planOverride }; + Assert.Equal(expected, TenantEntitlementService.GetMcpUsagePlanName(config, Now)); + } + + [Fact] + public void GetMcpUsagePlanName_ActiveTrial_GetsThePlanName_ButOverrideStillWins() + { + var trial = new TenantConfiguration { TenantId = TenantId, PlanTier = "free", TrialExpiresUtc = Now.AddDays(3) }; + Assert.Equal("pro", TenantEntitlementService.GetMcpUsagePlanName(trial, Now)); + trial.McpUsagePlanOverride = "msp"; + Assert.Equal("msp", TenantEntitlementService.GetMcpUsagePlanName(trial, Now)); + } + + [Fact] + public async Task GetMcpUsagePlanNameAsync_NoRow_FailsClosedToCommunity() + { + var (svc, _) = Build(config: null); + Assert.Equal("community", await svc.GetMcpUsagePlanNameAsync(TenantId)); + Assert.Equal("community", await svc.GetMcpUsagePlanNameAsync(null)); + } + + [Fact] + public async Task GetMcpUsagePlanNameAsync_StorageError_FailsClosedToCommunity() + { + var repo = new Mock(); + repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("storage down")); + var configService = new TenantConfigurationService( + repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); + var svc = new TenantEntitlementService( + configService, NullLogger.Instance, new TestTimeProvider(Now)); + + Assert.Equal("community", await svc.GetMcpUsagePlanNameAsync(TenantId)); + } + + [Fact] + public async Task GetMcpUsagePlanNameAsync_ReadsTheOverride() + { + var (svc, _) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "pro", McpUsagePlanOverride = "msp" }); + Assert.Equal("msp", await svc.GetMcpUsagePlanNameAsync(TenantId)); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 7af931a6..fc5bd9e9 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -745,6 +745,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "TrialGrantedBy", config.TrialGrantedBy }, { "ProDowngradedUtc", config.ProDowngradedUtc }, { "MaxDelegatedTenantsOverride", config.MaxDelegatedTenantsOverride }, + { "McpUsagePlanOverride", config.McpUsagePlanOverride }, // SLA targets { "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null }, { "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes }, @@ -861,6 +862,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage TrialGrantedBy = entity.GetString("TrialGrantedBy"), ProDowngradedUtc = entity.GetDateTime("ProDowngradedUtc"), MaxDelegatedTenantsOverride = entity.GetInt32("MaxDelegatedTenantsOverride"), + McpUsagePlanOverride = entity.GetString("McpUsagePlanOverride"), // SLA targets SlaTargetSuccessRate = entity.GetDouble("SlaTargetSuccessRate") != null ? (decimal)entity.GetDouble("SlaTargetSuccessRate")! : null, SlaTargetMaxDurationMinutes = entity.GetInt32("SlaTargetMaxDurationMinutes"), diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs index f2298ee0..3832f8e5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs @@ -121,7 +121,9 @@ namespace AutopilotMonitor.Functions.Functions.Config RetentionCapDays = entitlements.RetentionCapDays, UserRateLimitPerMinute = entitlements.UserRateLimitPerMinute, DelegatedAdminAllowed = entitlements.DelegatedAdminAllowed, - McpUsagePlan = entitlements.McpUsagePlanName, + // Effective plan NAME (tenant-wide GA override beats the edition default) — the per-user + // override in McpUsers is not a tenant property and stays out of the flags. + McpUsagePlan = TenantEntitlementService.GetMcpUsagePlanName(config, nowUtc), // Effective slot limit (override beats the catalog) — a count, computed from the config row. MaxDelegatedTenants = TenantEntitlementService.GetMaxDelegatedTenants(config, nowUtc) } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs index dd3ed209..47876486 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs @@ -59,8 +59,12 @@ namespace AutopilotMonitor.Functions.Functions.Config /// /// PATCH /api/config/{tenantId}/plan — GlobalAdminOnly (catalog-enforced). - /// Body: { "planTier"?: "community"|"pro", "trialExpiresUtc"?: ISO-8601 | null, "maxDelegatedTenants"?: int | null }. + /// Body: { "planTier"?: "community"|"pro", "trialExpiresUtc"?: ISO-8601 | null, "maxDelegatedTenants"?: int | null, + /// "mcpUsagePlan"?: string | null }. /// maxDelegatedTenants sets the delegated (MSP) slot override (null clears it — the plan entitlement applies). + /// mcpUsagePlan sets the tenant-wide MCP usage-plan override — the name of a SectionUsagePlans plan, validated + /// against the current definitions (400 for an unknown name, so a typo can never silently drop the tenant + /// to the Community fallback); null clears it (the edition default applies). It does NOT change the edition. /// The legacy stored value "enterprise" stays readable (resolves as Pro) but is no longer /// accepted on writes. /// Setting a trial date grants/extends the trial (TrialConsumed is NOT touched — GA @@ -93,6 +97,8 @@ namespace AutopilotMonitor.Functions.Functions.Config DateTime? newTrialExpiresUtc = null; bool slotsProvided = false; int? newMaxDelegatedTenants = null; + bool mcpPlanProvided = false; + string? newMcpUsagePlan = null; using (doc) { @@ -147,10 +153,41 @@ namespace AutopilotMonitor.Functions.Functions.Config return await BadRequestAsync(req, "maxDelegatedTenants must be a non-negative integer or null"); } } + + if (doc.RootElement.TryGetProperty("mcpUsagePlan", out var mcpPlanProp)) + { + mcpPlanProvided = true; + if (mcpPlanProp.ValueKind == JsonValueKind.Null) + { + newMcpUsagePlan = null; // explicit null = clear the override + } + else if (mcpPlanProp.ValueKind == JsonValueKind.String) + { + // Blank = clear as well (the select's "edition default" option); a name is validated below. + newMcpUsagePlan = TenantEntitlementService.NormalizePlanName(mcpPlanProp.GetString()); + } + else + { + return await BadRequestAsync(req, "mcpUsagePlan must be a plan name or null"); + } + } } - if (newPlanTier == null && !trialProvided && !slotsProvided) - return await BadRequestAsync(req, "Provide planTier, trialExpiresUtc and/or maxDelegatedTenants"); + if (newPlanTier == null && !trialProvided && !slotsProvided && !mcpPlanProvided) + return await BadRequestAsync(req, "Provide planTier, trialExpiresUtc, maxDelegatedTenants and/or mcpUsagePlan"); + + if (newMcpUsagePlan != null) + { + // Fail-loud on SET: an override naming a plan that exists nowhere would resolve fail-closed to + // the Community fallback at read time — silently shrinking the tenant instead of raising it. + var adminConfig = await _adminConfigService.GetConfigurationAsync(); + var definitions = PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson); + if (!IsKnownUsagePlan(newMcpUsagePlan, definitions)) + { + var known = string.Join(", ", KnownUsagePlanNames(definitions)); + return await BadRequestAsync(req, $"Unknown mcpUsagePlan '{newMcpUsagePlan}'. Known plans: {known}"); + } + } var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId); if (config == null) @@ -166,6 +203,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var (editionBefore, editionAfter) = ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes); ApplyDelegatedSlotChange(config, slotsProvided, newMaxDelegatedTenants, changes); + ApplyMcpUsagePlanChange(config, mcpPlanProvided, newMcpUsagePlan, changes); if (changes.Count > 0) { @@ -223,6 +261,8 @@ namespace AutopilotMonitor.Functions.Functions.Config RetentionGraceEndsUtc = TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc), MaxDelegatedTenants = TenantEntitlementService.GetMaxDelegatedTenants(config, nowUtc), MaxDelegatedTenantsOverride = config.MaxDelegatedTenantsOverride, + McpUsagePlan = TenantEntitlementService.GetMcpUsagePlanName(config, nowUtc), + McpUsagePlanOverride = config.McpUsagePlanOverride, }); return response; } @@ -304,6 +344,30 @@ namespace AutopilotMonitor.Functions.Functions.Config config.MaxDelegatedTenantsOverride = maxDelegatedTenants; } + /// + /// Pure mutation core for the tenant-wide MCP usage-plan override: records and applies the change only + /// when a value was provided AND differs (case-insensitive — the stored value is normalized on write). + /// The audit entry names "(edition default)" for the absent override. Validation of the plan name against + /// the definitions happens in the endpoint (I/O), not here. + /// + internal static void ApplyMcpUsagePlanChange( + TenantConfiguration config, bool provided, string? mcpUsagePlan, Dictionary changes) + { + var normalized = TenantEntitlementService.NormalizePlanName(mcpUsagePlan); + if (!provided || string.Equals(config.McpUsagePlanOverride, normalized, StringComparison.OrdinalIgnoreCase)) + return; + changes["McpUsagePlanOverride"] = + $"{config.McpUsagePlanOverride ?? "(edition default)"} -> {normalized ?? "(edition default)"}"; + config.McpUsagePlanOverride = normalized; + } + + /// Pure: whether (normalized) names a SectionUsagePlans definition. + internal static bool IsKnownUsagePlan(string planName, IEnumerable definitions) + => definitions.Any(d => string.Equals(TenantEntitlementService.NormalizePlanName(d.Name), planName, StringComparison.OrdinalIgnoreCase)); + + private static IEnumerable KnownUsagePlanNames(IEnumerable definitions) + => definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal); + /// /// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service /// 30-day Pro trial, exactly once per tenant. 409 when the trial was already diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs index fe678652..b9b35a87 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs @@ -26,6 +26,8 @@ namespace AutopilotMonitor.Functions.Helpers ("trialConsumed", c => c.TrialConsumed), // Delegated (MSP) slot override — a count, not a secret; the GA tenant list renders it. ("maxDelegatedTenantsOverride", c => c.MaxDelegatedTenantsOverride), + // MCP usage-plan override — a plan name, not a secret; the GA tenant list renders it. + ("mcpUsagePlanOverride", c => c.McpUsagePlanOverride), ("disabled", c => c.Disabled), ("disabledReason", c => c.DisabledReason), ("onboardedAt", c => c.OnboardedAt), diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs index 03930ea5..a5ce236e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs @@ -258,6 +258,9 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware /// The 429 body for a blocked decision. The message names WHOSE budget is exhausted — a member hitting the /// organization-wide window must not conclude their own plan is too small, and a delegated admin hitting /// a MANAGED tenant's window must learn that the managed tenant's plan (not their own) governs it. + /// Every Community block also says that Community is sized for occasional use and that Pro lifts the + /// window: the quota is the upgrade lever ("the budget follows the data" — a managed Community customer + /// is read with Community windows, so the fix is on the CUSTOMER's plan, never the MSP's). /// /// Display label (domain) of ; falls back to the id. /// Set on the aggregate block where EVERY managed tenant is exhausted. @@ -279,16 +282,24 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware var label = string.IsNullOrWhiteSpace(targetTenantLabel) ? decision.TargetTenantId : targetTenantLabel; var upgrade = FeatureEntitlementCatalog.IsPermanentProTier(decision.TenantPlan) ? string.Empty - : " Upgrading that tenant to Pro lifts its organization windows."; + : " That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows."; message = $"MCP {decision.Scope} request quota of the managed tenant '{label}' exceeded (tenant plan '{decision.TenantPlan}', shared by all its members and delegated admins).{upgrade} {reset}"; } else if (decision.Level == McpQuotaLevel.Tenant) { - message = $"MCP {decision.Scope} request quota of your organization exceeded (tenant plan '{decision.TenantPlan}', shared by all its members). {reset}"; + var upgrade = FeatureEntitlementCatalog.IsPermanentProTier(decision.TenantPlan) + ? string.Empty + : " The Community plan is sized for occasional use; upgrading your organization to Pro lifts its organization windows."; + message = $"MCP {decision.Scope} request quota of your organization exceeded (tenant plan '{decision.TenantPlan}', shared by all its members).{upgrade} {reset}"; } else { - message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. {reset}"; + // Only the Community EDITION plan gets the Pro hint — a per-user override plan (any other name) + // is already a deliberate individual budget, and "upgrade to Pro" would be wrong advice there. + var upgrade = string.Equals(decision.Plan, FeatureEntitlementCatalog.CommunityTierName, StringComparison.OrdinalIgnoreCase) + ? " The Community plan is sized for occasional use; Pro raises your daily and monthly windows." + : string.Empty; + message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'.{upgrade} {reset}"; } return new McpQuotaExceededResponse diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs index 556c5757..1d430218 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs @@ -222,9 +222,9 @@ namespace AutopilotMonitor.Functions.Services } } - // 2. Home-tenant edition default (fail-closed → Community inside the entitlement service). - var homeEntitlements = await _entitlementService.GetEntitlementsAsync(homeTenantId); - var planName = overridePlan ?? homeEntitlements.McpUsagePlanName; + // 2. Home-tenant plan: the tenant-wide GA override (TenantConfiguration.McpUsagePlanOverride) when + // set, else the edition default (fail-closed → Community inside the entitlement service). + var planName = overridePlan ?? await _entitlementService.GetMcpUsagePlanNameAsync(homeTenantId); // 3. Limits: admin-edited SectionUsagePlans definitions, else catalog fallbacks. var definitions = await LoadDefinitionsAsync(planName); @@ -242,14 +242,14 @@ namespace AutopilotMonitor.Functions.Services } /// - /// The CHARGED tenant's organization windows: its edition plan's definition when that carries tenant - /// limits (null = not set → the edition's catalog tenant limits; an explicit 0 lifts the window), never - /// a per-user override. + /// The CHARGED tenant's organization windows: its tenant plan (the tenant-wide GA override, else the + /// edition plan) definition when that carries tenant limits (null = not set → the edition's catalog + /// tenant limits; an explicit 0 lifts the window), never a per-user override. /// private async Task ResolveTenantLimitsAsync(string? chargeTenantId, List definitions) { var entitlements = await _entitlementService.GetEntitlementsAsync(chargeTenantId); - var tenantPlan = entitlements.McpUsagePlanName; + var tenantPlan = await _entitlementService.GetMcpUsagePlanNameAsync(chargeTenantId); var tenantDefinition = Find(definitions, tenantPlan); return new TenantPlanLimits( tenantPlan, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index d0dcaea3..17c69f48 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -112,7 +112,7 @@ namespace AutopilotMonitor.Functions.Services "LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy", "HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince", "PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy", - "ProDowngradedUtc", "MaxDelegatedTenantsOverride", + "ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", }; /// @@ -159,7 +159,7 @@ namespace AutopilotMonitor.Functions.Services "HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince", "OnboardedBy", "OnboardedAt", "PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy", - "ProDowngradedUtc", "MaxDelegatedTenantsOverride", + "ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", }; private static readonly Dictionary ModelProperties = @@ -507,7 +507,7 @@ namespace AutopilotMonitor.Functions.Services "HomedAppClientId" => "system-owned — written only by the app-homing flow", "LastAuthClientId" or "LastAuthClientIdSince" => "system-owned auth provenance", "PlanTier" or "TrialExpiresUtc" or "TrialStartedUtc" or "TrialConsumed" or "TrialGrantedBy" - or "MaxDelegatedTenantsOverride" + or "MaxDelegatedTenantsOverride" or "McpUsagePlanOverride" => "mutable only via the dedicated plan/trial endpoints", _ => "not writable via the field patch", }; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs index 7a91bcb7..62debc50 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs @@ -111,6 +111,46 @@ namespace AutopilotMonitor.Functions.Services } } + /// + /// The tenant's effective MCP usage plan NAME: the Global Admin override + /// (, a SectionUsagePlans plan name — applies + /// to the WHOLE tenant: every member's default user plan AND the organization windows) when set, + /// else the edition's catalog plan name. Precedence above this is the per-user override in McpUsers + /// (McpQuotaService). Does NOT change the edition — Pro feature gates stay on PlanTier. Normalized + /// (trimmed, lower-case) so it matches SectionUsagePlans lookups. + /// + public static string GetMcpUsagePlanName(TenantConfiguration config, DateTime nowUtc) + { + var overridePlan = NormalizePlanName(config.McpUsagePlanOverride); + return overridePlan ?? FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).McpUsagePlanName; + } + + /// Trimmed lower-case plan name, or null for blank input. + public static string? NormalizePlanName(string? planName) + => string.IsNullOrWhiteSpace(planName) ? null : planName.Trim().ToLowerInvariant(); + + /// + /// Cached read-time variant of . No row / any error ⇒ the Community + /// plan name (fail-closed — an override can never be granted by a failed lookup). + /// + public virtual async Task GetMcpUsagePlanNameAsync(string? tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + return FeatureEntitlementCatalog.CommunityTierName; + try + { + var config = await _configService.GetConfigurationIfExistsAsync(tenantId); + return config == null + ? FeatureEntitlementCatalog.CommunityTierName + : GetMcpUsagePlanName(config, _time.GetUtcNow().UtcDateTime); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "[Entitlement] MCP usage plan resolution failed for tenant {TenantId} — treating as Community (fail-closed)", tenantId); + return FeatureEntitlementCatalog.CommunityTierName; + } + } + /// /// Whether Unrestricted Mode is effectively ACTIVE for this config. Requires all three: /// the edition allows it (Pro — read-time, so trial expiry / downgrade re-arms the 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 2e9e8698..6cd7359d 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 @@ -3428,6 +3428,10 @@ export interface SetTenantPlanTierResponse { maxDelegatedTenants: number; /** The Global Admin override; omitted when the plan entitlement applies. */ maxDelegatedTenantsOverride?: number; + /** Effective MCP usage plan name of the tenant after the change (override or edition default). */ + mcpUsagePlan: string; + /** The Global Admin MCP usage-plan override (a SectionUsagePlans plan name); omitted when the edition default applies. */ + mcpUsagePlanOverride?: string; } /** Distinct-CVE counts grouped by their highest CVSS severity band. */ @@ -3706,6 +3710,8 @@ export interface TenantConfiguration { proDowngradedUtc?: string; /** Global-Admin override of the delegated ("MSP") tenant slot limit — how many distinct customer tenants users homed in this tenant may manage. Null = the plan entitlement applies (Community 0, Pro 2); a value applies regardless of edition (pre-provisioning a package), while USING delegation still requires Pro. Mutable only via the plan endpoint. Backend-only: not delivered to the agent (no ConfigVersion impact). */ maxDelegatedTenantsOverride?: number; + /** Global-Admin override of the tenant's MCP usage plan — the NAME of a SectionUsagePlans plan (AdminConfiguration.PlanTierDefinitionsJson), e.g. "msp". Applies to the WHOLE tenant: every member's default user plan (a per-user McpUsers override still wins) AND the organization-wide windows. Null/blank = the edition default (community/pro). Does NOT change the edition — Pro feature gates stay on PlanTier. Mutable only via the plan endpoint (validated against the plan definitions there). Backend-only: not delivered to the agent (no ConfigVersion impact). */ + mcpUsagePlanOverride?: string; /** Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") Comma-separated list */ manufacturerWhitelist: string; /** Hardware whitelist: Allowed models (supports wildcards like "Latitude*") Comma-separated list Default: "*" (all models allowed) */ diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 912ec762..bcb27dd4 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -203,6 +203,16 @@ namespace AutopilotMonitor.Shared.Models /// public int? MaxDelegatedTenantsOverride { get; set; } + /// + /// Global-Admin override of the tenant's MCP usage plan — the NAME of a SectionUsagePlans plan + /// (AdminConfiguration.PlanTierDefinitionsJson), e.g. "msp". Applies to the WHOLE tenant: every + /// member's default user plan (a per-user McpUsers override still wins) AND the organization-wide + /// windows. Null/blank = the edition default (community/pro). Does NOT change the edition — Pro + /// feature gates stay on PlanTier. Mutable only via the plan endpoint (validated against the plan + /// definitions there). Backend-only: not delivered to the agent (no ConfigVersion impact). + /// + public string? McpUsagePlanOverride { get; set; } + /// /// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") /// Comma-separated list diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs index cbb26c0b..c1c0dcf0 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs @@ -204,6 +204,12 @@ namespace AutopilotMonitor.Shared.Models /// The Global Admin override; omitted when the plan entitlement applies. public int? MaxDelegatedTenantsOverride { get; set; } + + /// Effective MCP usage plan name of the tenant after the change (override or edition default). + public string McpUsagePlan { get; set; } = default!; + + /// The Global Admin MCP usage-plan override (a SectionUsagePlans plan name); omitted when the edition default applies. + public string? McpUsagePlanOverride { get; set; } } /// 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 ed920181..fda599ac 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -1,7 +1,7 @@ "use client"; import { Suspense, useState, useEffect } from "react"; -import type { DelegatedSlotUsageResponse } from "@/utils/wire-types.generated"; +import type { DelegatedSlotUsageResponse, PlanTierDefinitionsResponse } from "@/utils/wire-types.generated"; import { useSearchParams } from "next/navigation"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; @@ -49,6 +49,11 @@ export interface TenantConfiguration { trialConsumed?: boolean; /** Delegated (MSP) tenant slot override; null/undefined = plan entitlement (Community 0, Pro 2). Managed via PATCH plan. */ maxDelegatedTenantsOverride?: number | null; + /** + * MCP usage-plan override — a SectionUsagePlans plan name applied to the WHOLE tenant (every member's + * default plan + the organization windows); null/undefined = edition default. Managed via PATCH plan. + */ + mcpUsagePlanOverride?: string | null; /** * Dual app-reg homing: null/undefined = legacy app. Typed explicitly so a payload refactor * cannot silently drop the field on the generic PUT round-trip (absent ⇒ backend resets to @@ -139,6 +144,8 @@ function TenantManagementSectionInner({ const [savingPlan, setSavingPlan] = useState(false); // Delegated (MSP) slot usage of the tenant being edited (GET global/delegated-slots/{id}); null = not loaded. const [slotUsage, setSlotUsage] = useState(null); + // SectionUsagePlans plan names — the valid values of the MCP usage-plan override select; null = not loaded. + const [usagePlanNames, setUsagePlanNames] = useState(null); const [releasingHold, setReleasingHold] = useState(null); // Support escape hatch: end a removed customer's 24 h slot hold early (GlobalAdminOnly, audited). const handleReleaseHold = async (tenantId: string, invitationId: string) => { @@ -182,6 +189,24 @@ function TenantManagementSectionInner({ cancelled = true; }; }, [editingTenantId, getAccessToken]); + useEffect(() => { + // Loaded once per editor open (the definitions rarely change); a failed load leaves the select + // with just the stored value + "edition default", so the editor stays usable. + if (!editingTenantId) return; + let cancelled = false; + authenticatedFetch(api.mcpUsage.planTiers(), getAccessToken) + .then(async (res) => { + if (cancelled) return; + const data = res.ok ? ((await res.json()) as PlanTierDefinitionsResponse) : null; + setUsagePlanNames(data ? data.tiers.map((t) => t.name.trim().toLowerCase()).filter(Boolean) : null); + }) + .catch(() => { + if (!cancelled) setUsagePlanNames(null); + }); + return () => { + cancelled = true; + }; + }, [editingTenantId, getAccessToken]); const [homingDialogTarget, setHomingDialogTarget] = useState<"primary" | "legacy" | null>(null); const [savingHoming, setSavingHoming] = useState(false); // Failures of the homing/offboard calls go into these instead of the page-level @@ -285,6 +310,7 @@ function TenantManagementSectionInner({ planTier: tenant.planTier === "pro" || tenant.planTier === "enterprise" ? "pro" : "community", trialExpiresUtc: tenant.trialExpiresUtc ?? null, maxDelegatedTenants: tenant.maxDelegatedTenantsOverride ?? null, + mcpUsagePlan: tenant.mcpUsagePlanOverride ?? null, }), }); @@ -300,6 +326,7 @@ function TenantManagementSectionInner({ trialExpiresUtc: result.trialExpiresUtc ?? null, trialConsumed: result.trialConsumed ?? t.trialConsumed, maxDelegatedTenantsOverride: result.maxDelegatedTenantsOverride ?? null, + mcpUsagePlanOverride: result.mcpUsagePlanOverride ?? null, }); setSlotUsage((prev) => (prev ? { ...prev, limit: result.maxDelegatedTenants ?? prev.limit, overrideLimit: result.maxDelegatedTenantsOverride ?? undefined } : prev)); setTenants(prev => prev.map(t => (t.tenantId === tenant.tenantId ? apply(t) : t))); @@ -939,6 +966,42 @@ function TenantManagementSectionInner({ (Community 0, Pro 2); a value applies regardless of plan, using delegation still requires Pro.

+
+ +
+ + {editingTenant.mcpUsagePlanOverride != null && ( + + )} +
+

+ Applies a usage plan (Admin → Settings → Usage Plans) to the whole tenant: every member’s default + MCP budget and the organization-wide windows. Blank = the edition’s plan (community/pro). Does not + change the edition; a per-user plan on the MCP Users page still wins for that account. +

+
{editingTenant.trialConsumed diff --git a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts index 851de22a..be5a5cf2 100644 --- a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts +++ b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts @@ -61,7 +61,7 @@ const SERVER_DENIED_FIELDS = new Set([ "lastUpdated", "updatedBy", "onboardedAt", "onboardedBy", "homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince", "planTier", "trialExpiresUtc", "trialStartedUtc", "trialConsumed", "trialGrantedBy", - "proDowngradedUtc", "maxDelegatedTenantsOverride", + "proDowngradedUtc", "maxDelegatedTenantsOverride", "mcpUsagePlanOverride", ]); describe("SECTION_FIELD_MAP parity", () => { diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index f369f6c0..5570145e 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -140,6 +140,8 @@ export interface TenantConfiguration { trialConsumed?: boolean; /** Delegated (MSP) tenant slot override; null = plan entitlement. GA-only, via PATCH plan. */ maxDelegatedTenantsOverride?: number | null; + /** MCP usage-plan override (a SectionUsagePlans plan name); null = edition default. GA-only, via PATCH plan. */ + mcpUsagePlanOverride?: string | null; } // Wire type is generated from the backend DTO ("role" is absent for legacy pre-role rows). diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 7622bb15..49047f74 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -87,6 +87,7 @@ export const SHARED_MANIFEST = { "trialGrantedBy", "proDowngradedUtc", "maxDelegatedTenantsOverride", + "mcpUsagePlanOverride", "manufacturerWhitelist", "modelWhitelist", "validateAutopilotDevice", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 52f79473..44b27b30 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -83,6 +83,7 @@ "trialGrantedBy", "proDowngradedUtc", "maxDelegatedTenantsOverride", + "mcpUsagePlanOverride", "manufacturerWhitelist", "modelWhitelist", "validateAutopilotDevice", @@ -15983,6 +15984,23 @@ }, "optional": true, "doc": "The Global Admin override; omitted when the plan entitlement applies." + }, + { + "name": "mcpUsagePlan", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Effective MCP usage plan name of the tenant after the change (override or edition default)." + }, + { + "name": "mcpUsagePlanOverride", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "The Global Admin MCP usage-plan override (a SectionUsagePlans plan name); omitted when the edition default applies." } ] }, @@ -17157,6 +17175,15 @@ "optional": true, "doc": "Global-Admin override of the delegated (\"MSP\") tenant slot limit — how many distinct customer tenants users homed in this tenant may manage. Null = the plan entitlement applies (Community 0, Pro 2); a value applies regardless of edition (pre-provisioning a package), while USING delegation still requires Pro. Mutable only via the plan endpoint. Backend-only: not delivered to the agent (no ConfigVersion impact)." }, + { + "name": "mcpUsagePlanOverride", + "type": { + "kind": "primitive", + "name": "string" + }, + "optional": true, + "doc": "Global-Admin override of the tenant's MCP usage plan — the NAME of a SectionUsagePlans plan (AdminConfiguration.PlanTierDefinitionsJson), e.g. \"msp\". Applies to the WHOLE tenant: every member's default user plan (a per-user McpUsers override still wins) AND the organization-wide windows. Null/blank = the edition default (community/pro). Does NOT change the edition — Pro feature gates stay on PlanTier. Mutable only via the plan endpoint (validated against the plan definitions there). Backend-only: not delivered to the agent (no ConfigVersion impact)." + }, { "name": "manufacturerWhitelist", "type": { diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 7edee15f..0451c2d0 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -3428,6 +3428,10 @@ export interface SetTenantPlanTierResponse { maxDelegatedTenants: number; /** The Global Admin override; omitted when the plan entitlement applies. */ maxDelegatedTenantsOverride?: number; + /** Effective MCP usage plan name of the tenant after the change (override or edition default). */ + mcpUsagePlan: string; + /** The Global Admin MCP usage-plan override (a SectionUsagePlans plan name); omitted when the edition default applies. */ + mcpUsagePlanOverride?: string; } /** Distinct-CVE counts grouped by their highest CVSS severity band. */ @@ -3706,6 +3710,8 @@ export interface TenantConfiguration { proDowngradedUtc?: string; /** Global-Admin override of the delegated ("MSP") tenant slot limit — how many distinct customer tenants users homed in this tenant may manage. Null = the plan entitlement applies (Community 0, Pro 2); a value applies regardless of edition (pre-provisioning a package), while USING delegation still requires Pro. Mutable only via the plan endpoint. Backend-only: not delivered to the agent (no ConfigVersion impact). */ maxDelegatedTenantsOverride?: number; + /** Global-Admin override of the tenant's MCP usage plan — the NAME of a SectionUsagePlans plan (AdminConfiguration.PlanTierDefinitionsJson), e.g. "msp". Applies to the WHOLE tenant: every member's default user plan (a per-user McpUsers override still wins) AND the organization-wide windows. Null/blank = the edition default (community/pro). Does NOT change the edition — Pro feature gates stay on PlanTier. Mutable only via the plan endpoint (validated against the plan definitions there). Backend-only: not delivered to the agent (no ConfigVersion impact). */ + mcpUsagePlanOverride?: string; /** Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") Comma-separated list */ manufacturerWhitelist: string; /** Hardware whitelist: Allowed models (supports wildcards like "Latitude*") Comma-separated list Default: "*" (all models allowed) */