From eb47dfb6ce0770742e4ddcee1dad58f14412904d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 2 Sep 2026 10:32:15 +0200 Subject: [PATCH] feat(mcp): organization-wide MCP quota next to the per-user quota Every X-Client-Source: mcp request is now charged against two budgets: the caller's own daily/monthly windows (per-user override or tenant edition plan) and the organization-wide daily/monthly windows of the token's tenant, which always follow the tenant's edition plan so a per-user override never widens them. Ten accounts no longer mean ten times the budget. Tenant counters live in the new McpTenantUsage table (PK = tenantId, RK = {yyyyMMdd}_{oid}: one partition range per check, no hot row, never a property scan over UserUsageLog); exact-PK offboarding bucket and 90-day retention included. PlanTierDefinition gains nullable tenant limits (null = catalog fallback: Community 300/9000, Pro 3000/60000; 0 = unlimited). The 429 body carries level=user|tenant and a message naming whose budget it is, X-MCP-Quota-Tenant-* headers ride along, and mcp-usage/me exposes both budgets (wire types regenerated). MCP server: the quota 429 body was rendered as Error: undefined because it has no error key; it now gets a dedicated block with the reset time and a level-specific suggestion. Web: Usage Plans edits the tenant limits; the self-service MCP Usage page renders both budgets (the quota node was never displayed before). Co-Authored-By: Claude Fable 5.1 --- docs | 2 +- .../ConfigWireParityTests.cs | 4 + .../FeatureEntitlementCatalogTests.cs | 4 + .../InfrastructureWireParityTests.cs | 86 +++----- .../McpQuotaServiceTests.cs | 191 +++++++++++++++++- .../MetricsWireParityTests.cs | 33 ++- .../TableStorage/TableUserUsageRepository.cs | 117 ++++++++++- .../Metrics/McpUsageMetricsFunction.cs | 7 +- .../McpQuotaEnforcementMiddleware.cs | 51 ++++- .../Security/FeatureEntitlementCatalog.cs | 17 +- .../MaintenanceService.Aggregation.cs | 16 +- .../Services/McpQuotaService.cs | 182 ++++++++++++----- .../Offboarding/TenantOffboardingHandler.cs | 2 + .../src/__tests__/error-handler.test.ts | 42 ++++ .../src/generated/wire-types.generated.ts | 15 +- .../src/tools/error-handler.ts | 15 ++ .../AutopilotMonitor.Shared/Constants.cs | 5 + .../DataAccess/IUserUsageRepository.cs | 21 ++ .../Models/Config/PlanTierDefinition.cs | 11 +- .../Models/InfrastructureApiModels.cs | 3 + .../Models/MetricsApiModels.cs | 10 +- .../settings/sections/SectionUsagePlans.tsx | 48 ++++- .../reporting/sections/SectionMcpUsage.tsx | 68 ++++++- .../utils/shared-manifests.json | 65 +++++- .../utils/wire-types.generated.ts | 15 +- 25 files changed, 884 insertions(+), 146 deletions(-) diff --git a/docs b/docs index bf2371a8..3824bdfa 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit bf2371a89cc80b3605193a644947aadb1b179591 +Subproject commit 3824bdfad60028956bdca196c3d89610b637b9f4 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs index 19515bb4..8f1b4072 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs @@ -541,6 +541,10 @@ public class ConfigWireParityTests DailyRequestLimit = 1000, MonthlyRequestLimit = 30000, Description = "Paid tier", + // Organization-wide windows; the community tier above leaves them unset (catalog fallback, + // keys omitted on the wire). + TenantDailyRequestLimit = 3000, + TenantMonthlyRequestLimit = 60000, }, }; diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs index aac2c75a..44e0f391 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs @@ -102,6 +102,8 @@ public class FeatureEntitlementCatalogTests Assert.Equal("community", e.McpUsagePlanName); Assert.Equal(100, e.McpDailyRequestLimit); Assert.Equal(3000, e.McpMonthlyRequestLimit); + Assert.Equal(300, e.McpTenantDailyRequestLimit); + Assert.Equal(9000, e.McpTenantMonthlyRequestLimit); } [Fact] @@ -117,6 +119,8 @@ public class FeatureEntitlementCatalogTests Assert.Equal("pro", e.McpUsagePlanName); Assert.Equal(1000, e.McpDailyRequestLimit); Assert.Equal(20000, e.McpMonthlyRequestLimit); + Assert.Equal(3000, e.McpTenantDailyRequestLimit); + Assert.Equal(60000, e.McpTenantMonthlyRequestLimit); } [Fact] diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs index c232613b..260dd8a2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs @@ -1,3 +1,4 @@ +using AutopilotMonitor.Functions.Middleware; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; @@ -644,77 +645,50 @@ public class InfrastructureWireParityTests [Fact] public void McpQuotaExceededResponse_matches_the_daily_blocked_shape() { - var decision = new McpQuotaDecision - { - Allowed = false, - Plan = "community", - Scope = "daily", - DailyLimit = 200, - MonthlyLimit = 2000, - DailyUsed = 200, - MonthlyUsed = 940, - ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc), - }; + // The caller's OWN daily window: level=user, limit/used from the user counters, plan named. + var decision = McpQuotaService.BuildDecision( + new McpPlanLimits("community", 200, 2000, "community", 600, 6000), + dailyUsed: 200, monthlyUsed: 940, tenantDailyUsed: 250, tenantMonthlyUsed: 1200, + new DateTime(2026, 8, 30, 15, 0, 0, DateTimeKind.Utc)); AssertParity( new { quotaExceeded = true, - plan = decision.Plan, - scope = decision.Scope, - limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit, - used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed, - resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), - message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z." + plan = "community", + scope = "daily", + level = "user", + 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." }, - new McpQuotaExceededResponse - { - QuotaExceeded = true, - Plan = decision.Plan, - Scope = decision.Scope, - Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit, - Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed, - ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), - Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z." - }); + McpQuotaEnforcementMiddleware.BuildExceededResponse(decision)); } [Fact] - public void McpQuotaExceededResponse_matches_the_monthly_blocked_shape() + public void McpQuotaExceededResponse_matches_the_tenant_monthly_blocked_shape() { - var decision = new McpQuotaDecision - { - Allowed = false, - Plan = "pro", - Scope = "monthly", - DailyLimit = 1000, - MonthlyLimit = 10000, - DailyUsed = 120, - MonthlyUsed = 10000, - ResetUtc = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc), - }; + // The ORGANIZATION's monthly window while the caller is well inside their own plan: level=tenant, + // limit/used from the tenant counters, the message names the tenant plan (not the user's override). + var decision = McpQuotaService.BuildDecision( + new McpPlanLimits("power", 10000, 100000, "pro", 3000, 60000), + dailyUsed: 120, monthlyUsed: 5000, tenantDailyUsed: 900, tenantMonthlyUsed: 60000, + new DateTime(2026, 8, 30, 15, 0, 0, DateTimeKind.Utc)); AssertParity( new { quotaExceeded = true, - plan = decision.Plan, - scope = decision.Scope, - limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit, - used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed, - resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), - message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z." + plan = "power", + scope = "monthly", + level = "tenant", + limit = 60000, + used = 60000L, + resetUtc = "2026-09-01T00:00:00Z", + message = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z." }, - new McpQuotaExceededResponse - { - QuotaExceeded = true, - Plan = decision.Plan, - Scope = decision.Scope, - Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit, - Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed, - ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), - Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z." - }); + McpQuotaEnforcementMiddleware.BuildExceededResponse(decision)); } [Fact] @@ -730,6 +704,7 @@ public class InfrastructureWireParityTests quotaExceeded = true, plan = "community", scope, + level = "user", limit = 200, used = 200L, resetUtc = "2026-08-31T00:00:00Z", @@ -740,6 +715,7 @@ public class InfrastructureWireParityTests QuotaExceeded = true, Plan = "community", Scope = null, + Level = "user", Limit = 200, Used = 200, ResetUtc = "2026-08-31T00:00:00Z", diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs index 2ea73ae6..77b5a402 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs @@ -22,29 +22,41 @@ public class McpQuotaServiceTests // ── BuildDecision (pure window math) ───────────────────────────────────────── + private static McpPlanLimits Limits( + int daily = 100, int monthly = 3000, int tenantDaily = 300, int tenantMonthly = 9000, string plan = "community") + => new(plan, daily, monthly, plan, tenantDaily, tenantMonthly); + + private static McpQuotaDecision Decide( + McpPlanLimits limits, long dailyUsed, long monthlyUsed, long tenantDailyUsed = 0, long tenantMonthlyUsed = 0) + => McpQuotaService.BuildDecision(limits, dailyUsed, monthlyUsed, tenantDailyUsed, tenantMonthlyUsed, Now); + [Fact] - public void BuildDecision_UnderBothLimits_Allowed() + public void BuildDecision_UnderAllLimits_Allowed() { - var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 99, monthlyUsed: 500, Now); + var d = Decide(Limits(), dailyUsed: 99, monthlyUsed: 500, tenantDailyUsed: 299, tenantMonthlyUsed: 8999); Assert.True(d.Allowed); Assert.Null(d.Scope); + Assert.Null(d.Level); } [Fact] public void BuildDecision_DailyExceeded_BlocksWithMidnightReset() { - var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 100, monthlyUsed: 500, Now); + var d = Decide(Limits(), dailyUsed: 100, monthlyUsed: 500); Assert.False(d.Allowed); Assert.Equal("daily", d.Scope); + Assert.Equal("user", d.Level); + Assert.Equal(100, d.ExceededLimit); Assert.Equal(new DateTime(2026, 7, 8, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc); } [Fact] public void BuildDecision_MonthlyExceeded_BlocksWithFirstOfNextMonthReset() { - var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 10, monthlyUsed: 3000, Now); + var d = Decide(Limits(), dailyUsed: 10, monthlyUsed: 3000); Assert.False(d.Allowed); Assert.Equal("monthly", d.Scope); + Assert.Equal("user", d.Level); Assert.Equal(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc); } @@ -52,17 +64,59 @@ public class McpQuotaServiceTests public void BuildDecision_MonthlyTakesPrecedenceOverDaily() { // Both exceeded → report the longer (monthly) window so Retry-After is honest. - var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 100, monthlyUsed: 3000, Now); + var d = Decide(Limits(), dailyUsed: 100, monthlyUsed: 3000); Assert.Equal("monthly", d.Scope); } [Fact] - public void BuildDecision_ZeroLimit_MeansUnlimitedForThatScope() + public void BuildDecision_ZeroLimit_MeansUnlimitedForThatWindow() { - var d = McpQuotaService.BuildDecision("custom", 0, 0, dailyUsed: 999999, monthlyUsed: 999999, Now); + var d = Decide(Limits(0, 0, 0, 0, "custom"), dailyUsed: 999999, monthlyUsed: 999999, tenantDailyUsed: 999999, tenantMonthlyUsed: 999999); Assert.True(d.Allowed); } + [Fact] + public void BuildDecision_TenantDailyExceeded_BlocksAMemberInsideTheirOwnPlan() + { + // The organization's window is exhausted by OTHER members — this caller has used 5 of 100. + var d = Decide(Limits(), dailyUsed: 5, monthlyUsed: 5, tenantDailyUsed: 300, tenantMonthlyUsed: 300); + Assert.False(d.Allowed); + Assert.Equal("daily", d.Scope); + Assert.Equal("tenant", d.Level); + Assert.Equal(300, d.ExceededLimit); + Assert.Equal(300, d.ExceededUsed); + Assert.Equal(new DateTime(2026, 7, 8, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc); + } + + [Fact] + public void BuildDecision_TenantMonthlyExceeded_ResetsOnTheFirst() + { + var d = Decide(Limits(), dailyUsed: 5, monthlyUsed: 50, tenantDailyUsed: 10, tenantMonthlyUsed: 9000); + Assert.False(d.Allowed); + Assert.Equal("monthly", d.Scope); + Assert.Equal("tenant", d.Level); + Assert.Equal(9000, d.ExceededLimit); + Assert.Equal(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc); + } + + [Fact] + public void BuildDecision_UserMonthly_IsNamedBeforeTenantMonthly() + { + // Same reset either way; the caller's OWN exhausted budget is the more actionable message. + var d = Decide(Limits(), dailyUsed: 10, monthlyUsed: 3000, tenantDailyUsed: 10, tenantMonthlyUsed: 9000); + Assert.Equal("monthly", d.Scope); + Assert.Equal("user", d.Level); + } + + [Fact] + public void BuildDecision_TenantMonthly_IsNamedBeforeUserDaily() + { + // Longest exceeded window first: a tenant-monthly block outranks the caller's daily block. + var d = Decide(Limits(), dailyUsed: 100, monthlyUsed: 500, tenantDailyUsed: 10, tenantMonthlyUsed: 9000); + Assert.Equal("monthly", d.Scope); + Assert.Equal("tenant", d.Level); + } + // ── CheckAsync (integration over mocked deps) ──────────────────────────────── private static McpQuotaService Build( @@ -105,6 +159,9 @@ public class McpQuotaServiceTests } private static Mock UsageRepo(params (string Date, long Count)[] rows) + => UsageRepo(rows, tenantRows: Array.Empty<(string, long)>()); + + private static Mock UsageRepo((string Date, long Count)[] rows, (string Date, long Count)[] tenantRows) { var repo = new Mock(); repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny(), It.IsAny())) @@ -114,6 +171,14 @@ public class McpQuotaServiceTests Date = r.Date, RequestCount = r.Count }).ToList()); + repo.Setup(r => r.GetTenantUsageAsync(TenantId, It.IsAny(), It.IsAny())) + .ReturnsAsync(tenantRows.Select(r => new TenantUsageRecord + { + TenantId = TenantId, + UserId = "some-other-member", + Date = r.Date, + RequestCount = r.Count + }).ToList()); return repo; } @@ -300,4 +365,116 @@ public class McpQuotaServiceTests Assert.Equal("daily", decision.Scope); Assert.Equal(150, decision.DailyUsed); } + + // ── Tenant (organization-wide) quota ───────────────────────────────────────── + + [Fact] + public async Task Check_TenantDailyExceeded_BlocksAMemberUnderTheirOwnLimit() + { + // Community catalog tenant window = 300/day; other members burned it, this caller made 5 requests. + var svc = Build(UsageRepo(new[] { ("20260707", 5L) }, tenantRows: new[] { ("20260707", 300L) })); + + var d = await svc.CheckAsync(Oid, Upn, TenantId); + + Assert.False(d.Allowed); + Assert.Equal("tenant", d.Level); + Assert.Equal("daily", d.Scope); + Assert.Equal("community", d.TenantPlan); + Assert.Equal(300, d.TenantDailyLimit); + Assert.Equal(300, d.TenantDailyUsed); + Assert.Equal(5, d.DailyUsed); + } + + [Fact] + public async Task Check_TenantMonthlySum_SpansAllMembersAndDaysOfTheMonth() + { + var svc = Build(UsageRepo( + new[] { ("20260707", 1L) }, + tenantRows: new[] { ("20260701", 4000L), ("20260703", 4990L), ("20260707", 10L) })); + + var d = await svc.CheckAsync(Oid, Upn, TenantId); + + Assert.False(d.Allowed); + Assert.Equal("tenant", d.Level); + Assert.Equal("monthly", d.Scope); + Assert.Equal(9000, d.TenantMonthlyUsed); + Assert.Equal(10, d.TenantDailyUsed); + } + + [Fact] + public async Task Check_TenantLimits_FollowTheTenantPlan_NeverThePerUserOverride() + { + // The caller's override plan is huge; the tenant's edition plan (community) carries a tiny tenant + // window. The override lifts only the caller's OWN budget — the organization's window still bites. + var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"tenantDailyRequestLimit":1000000,"tenantMonthlyRequestLimit":1000000,"description":""},{"name":"community","dailyRequestLimit":100,"monthlyRequestLimit":3000,"tenantDailyRequestLimit":10,"tenantMonthlyRequestLimit":100,"description":""}]"""; + var svc = Build(UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 10L) }), + planDefinitionsJson: json, mcpUserPlanOverride: "power"); + + var d = await svc.CheckAsync(Oid, Upn, TenantId); + + Assert.False(d.Allowed); + Assert.Equal("power", d.Plan); + Assert.Equal(10000, d.DailyLimit); + Assert.Equal("community", d.TenantPlan); + Assert.Equal(10, d.TenantDailyLimit); + Assert.Equal("tenant", d.Level); + } + + [Fact] + public async Task Check_DefinitionWithoutTenantLimits_FallsBackToTheEditionCatalog() + { + // Pre-existing SectionUsagePlans rows carry no tenant fields → the edition's catalog tenant windows. + var json = """[{"name":"pro","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]"""; + var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro); + + var d = await svc.CheckAsync(Oid, Upn, TenantId); + + Assert.True(d.Allowed); + Assert.Equal(5, d.DailyLimit); + Assert.Equal(3000, d.TenantDailyLimit); + Assert.Equal(60000, d.TenantMonthlyLimit); + } + + [Fact] + public async Task Check_TenantWindowsLiftedToZero_SkipTheTenantRead() + { + var json = """[{"name":"community","dailyRequestLimit":100,"monthlyRequestLimit":3000,"tenantDailyRequestLimit":0,"tenantMonthlyRequestLimit":0,"description":""}]"""; + var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 999999L) }); + var svc = Build(repo, planDefinitionsJson: json); + + var d = await svc.CheckAsync(Oid, Upn, TenantId); + + Assert.True(d.Allowed); + Assert.Equal(0, d.TenantDailyLimit); + repo.Verify(r => r.GetTenantUsageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Check_WithoutTenantId_SkipsTheTenantRead() + { + var repo = UsageRepo(("20260707", 1)); + var svc = Build(repo); + + var d = await svc.CheckAsync(Oid, Upn, tenantId: null); + + Assert.True(d.Allowed); + repo.Verify(r => r.GetTenantUsageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Check_TenantLookupThrows_FailsOpen_AndDoesNotCache() + { + var repo = UsageRepo(("20260707", 1)); + repo.Setup(r => r.GetTenantUsageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("storage down")); + var svc = Build(repo); + + var first = await svc.CheckAsync(Oid, Upn, TenantId); + var second = await svc.CheckAsync(Oid, Upn, TenantId); + + Assert.True(first.Allowed); + Assert.True(second.Allowed); + Assert.Equal(-1, first.TenantDailyUsed); + repo.Verify(r => r.GetTenantUsageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(2)); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs index f3fda112..5c4e4cce 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/MetricsWireParityTests.cs @@ -402,6 +402,11 @@ public class MetricsWireParityTests long dailyUsed = 42; long monthlyUsed = 950; var resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc); + var tenantPlan = "community"; + var tenantDailyLimit = 300; + var tenantMonthlyLimit = 9000; + long tenantDailyUsed = 120; + long tenantMonthlyUsed = 4100; var records = SampleUsageRecords(userId); AssertParity( @@ -417,7 +422,12 @@ public class MetricsWireParityTests monthlyLimit, dailyUsed, monthlyUsed, - resetUtc + resetUtc, + tenantPlan, + tenantDailyLimit, + tenantMonthlyLimit, + tenantDailyUsed, + tenantMonthlyUsed }, records }, @@ -433,7 +443,12 @@ public class MetricsWireParityTests MonthlyLimit = monthlyLimit, DailyUsed = dailyUsed, MonthlyUsed = monthlyUsed, - ResetUtc = resetUtc + ResetUtc = resetUtc, + TenantPlan = tenantPlan, + TenantDailyLimit = tenantDailyLimit, + TenantMonthlyLimit = tenantMonthlyLimit, + TenantDailyUsed = tenantDailyUsed, + TenantMonthlyUsed = tenantMonthlyUsed }, Records = records }); @@ -461,7 +476,12 @@ public class MetricsWireParityTests monthlyLimit = 1000, dailyUsed = 0L, monthlyUsed = 0L, - resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc) + resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc), + tenantPlan = "free", + tenantDailyLimit = 0, + tenantMonthlyLimit = 0, + tenantDailyUsed = 0L, + tenantMonthlyUsed = 0L }, records }, @@ -477,7 +497,12 @@ public class MetricsWireParityTests MonthlyLimit = 1000, DailyUsed = 0, MonthlyUsed = 0, - ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc) + ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc), + TenantPlan = "free", + TenantDailyLimit = 0, + TenantMonthlyLimit = 0, + TenantDailyUsed = 0, + TenantMonthlyUsed = 0 }, Records = records }); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableUserUsageRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableUserUsageRepository.cs index 2ac99397..36198cf7 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableUserUsageRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableUserUsageRepository.cs @@ -11,11 +11,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { /// /// Table Storage implementation of IUserUsageRepository. - /// PartitionKey: userId (oid claim), RowKey: {yyyyMMdd}_{normalizedEndpoint} + /// UserUsageLog — PartitionKey: userId (oid claim), RowKey: {yyyyMMdd}_{normalizedEndpoint}. + /// McpTenantUsage — PartitionKey: tenantId, RowKey: {yyyyMMdd}_{userId} (the organization-wide quota + /// counter; per user so concurrent members never contend on one row, read as one partition range). /// public class TableUserUsageRepository : IUserUsageRepository { private readonly TableClient _tableClient; + private readonly TableClient _tenantTableClient; private readonly ILogger _logger; public TableUserUsageRepository( @@ -24,6 +27,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { _logger = logger; _tableClient = storage.GetTableClient(Constants.TableNames.UserUsageLog); + _tenantTableClient = storage.GetTableClient(Constants.TableNames.McpTenantUsage); } public async Task IncrementUsageAsync(string userId, string userPrincipalName, string tenantId, string endpoint) @@ -202,6 +206,117 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage return deleted; } + // ---- McpTenantUsage (organization-wide quota counters) ---- + + public async Task IncrementTenantUsageAsync(string tenantId, string userId) + { + if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userId)) + return; + + var date = DateTime.UtcNow.ToString("yyyyMMdd"); + var rowKey = $"{date}_{userId}"; + + const int maxRetries = 3; + for (var attempt = 0; attempt < maxRetries; attempt++) + { + try + { + var result = await _tenantTableClient.GetEntityAsync(tenantId, rowKey); + var entity = result.Value; + var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L; + entity["RequestCount"] = count + 1; + entity["LastRequestAt"] = DateTimeOffset.UtcNow; + await _tenantTableClient.UpdateEntityAsync(entity, entity.ETag); + return; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + var entity = new TableEntity(tenantId, rowKey) + { + ["Date"] = date, + ["UserId"] = userId, + ["RequestCount"] = 1L, + ["LastRequestAt"] = DateTimeOffset.UtcNow, + }; + try + { + await _tenantTableClient.AddEntityAsync(entity); + return; + } + catch (RequestFailedException addEx) when (addEx.Status == 409) + { + continue; + } + } + catch (RequestFailedException ex) when (ex.Status == 412) + { + continue; + } + } + + _logger.LogWarning("Failed to increment tenant usage after {MaxRetries} retries: tenant={TenantId}, user={UserId}", + maxRetries, LogSanitizer.Clean(tenantId), LogSanitizer.Clean(userId)); + } + + public async Task> GetTenantUsageAsync(string tenantId, string? dateFrom = null, string? dateTo = null) + { + var records = new List(); + if (string.IsNullOrWhiteSpace(tenantId)) + return records; + + await foreach (var entity in _tenantTableClient.QueryAsync(filter: BuildTenantCounterFilter(tenantId, dateFrom, dateTo))) + { + records.Add(new TenantUsageRecord + { + TenantId = entity.PartitionKey, + UserId = entity.GetString("UserId") ?? string.Empty, + Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0], + RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L, + }); + } + return records; + } + + /// + /// Partition + RowKey range filter for McpTenantUsage: the RowKey starts with the yyyyMMdd day, so a + /// date window is a key range ("{to}~" sorts after every "{to}_{oid}"); no property scan. + /// + internal static string BuildTenantCounterFilter(string tenantId, string? dateFrom, string? dateTo) + { + var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'"; + if (!string.IsNullOrEmpty(dateFrom)) + filter += $" and RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'"; + if (!string.IsNullOrEmpty(dateTo)) + filter += $" and RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'"; + return filter; + } + + public async Task DeleteTenantRecordsOlderThanAsync(string dateCutoff) + { + var filter = $"RowKey lt '{ODataSanitizer.EscapeValue(dateCutoff)}'"; + var toDelete = new List<(string partitionKey, string rowKey)>(); + await foreach (var entity in _tenantTableClient.QueryAsync( + filter: filter, select: new[] { "PartitionKey", "RowKey" })) + { + toDelete.Add((entity.PartitionKey, entity.RowKey)); + } + + int deleted = 0; + foreach (var (pk, rk) in toDelete) + { + try + { + await _tenantTableClient.DeleteEntityAsync(pk, rk); + deleted++; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // Already deleted, skip + } + } + return deleted; + } + private static UserUsageRecord MapToRecord(TableEntity entity) { return new UserUsageRecord diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs index 57e6d69c..d71751e7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs @@ -82,7 +82,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics MonthlyLimit = quota.MonthlyLimit, DailyUsed = quota.DailyUsed, MonthlyUsed = quota.MonthlyUsed, - ResetUtc = quota.ResetUtc + ResetUtc = quota.ResetUtc, + TenantPlan = quota.TenantPlan, + TenantDailyLimit = quota.TenantDailyLimit, + TenantMonthlyLimit = quota.TenantMonthlyLimit, + TenantDailyUsed = quota.TenantDailyUsed, + TenantMonthlyUsed = quota.TenantMonthlyUsed }, Records = records }); diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs index 1e5abacf..19a6efa5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs @@ -12,7 +12,8 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Middleware; /// -/// Enforces the per-user MCP daily/monthly request quota. Runs after +/// Enforces the MCP daily/monthly request quota — the caller's own budget AND the organization-wide +/// budget of the token's tenant (both counted here, both must be free). Runs after /// (per-minute burst control) — this is the budget layer on /// top. Applies ONLY to HTTP requests marked X-Client-Source: mcp with an authenticated /// principal (oid); everything else passes through untouched. Global Admins are exempt from the @@ -118,22 +119,38 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware var retryAfterSeconds = Math.Max(1, (int)(decision.ResetUtc - DateTime.UtcNow).TotalSeconds); _logger.LogWarning( - "[McpQuota] BLOCKED oid={Oid} plan={Plan} scope={Scope} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit}", - oid, decision.Plan, decision.Scope, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit); + "[McpQuota] BLOCKED oid={Oid} tenant={TenantId} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}", + oid, tenantId, decision.Level, decision.Scope, decision.Plan, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit, + decision.TenantDailyUsed, decision.TenantDailyLimit, decision.TenantMonthlyUsed, decision.TenantMonthlyLimit); httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests; httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString(); httpContext.Response.ContentType = "application/json"; - await httpContext.Response.WriteAsJsonAsync(new McpQuotaExceededResponse + await httpContext.Response.WriteAsJsonAsync(BuildExceededResponse(decision)); + } + + /// + /// 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. + /// + internal static McpQuotaExceededResponse BuildExceededResponse(McpQuotaDecision decision) + { + var reset = $"Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."; + var message = decision.Level == McpQuotaLevel.Tenant + ? $"MCP {decision.Scope} request quota of your organization exceeded (tenant plan '{decision.TenantPlan}', shared by all its members). {reset}" + : $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. {reset}"; + + return new McpQuotaExceededResponse { QuotaExceeded = true, Plan = decision.Plan, Scope = decision.Scope, - Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit, - Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed, + Level = decision.Level ?? McpQuotaLevel.User, + Limit = decision.ExceededLimit, + Used = decision.ExceededUsed, ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"), - Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z." - }); + Message = message + }; } /// @@ -162,6 +179,19 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware { logger.LogWarning(ex, "[McpQuota] Failed to record usage: user={UserId}, endpoint={Endpoint}", LogSanitizer.Clean(oid), LogSanitizer.Clean(normalizedEndpoint)); } + + // Organization-wide counter (the tenant quota's source). Separate try so a failure on one + // table never starves the other; GA usage lands here too (exempt from the check, not from the count). + if (tidValue.Length == 0) + return; + try + { + await repo.IncrementTenantUsageAsync(tidValue, oid); + } + catch (Exception ex) + { + logger.LogWarning(ex, "[McpQuota] Failed to record tenant usage: tenant={TenantId}, user={UserId}", LogSanitizer.Clean(tidValue), LogSanitizer.Clean(oid)); + } }); } @@ -171,10 +201,15 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware httpContext.Response.Headers["X-MCP-Quota-Plan"] = decision.Plan; httpContext.Response.Headers["X-MCP-Quota-Daily-Limit"] = decision.DailyLimit.ToString(); httpContext.Response.Headers["X-MCP-Quota-Monthly-Limit"] = decision.MonthlyLimit.ToString(); + httpContext.Response.Headers["X-MCP-Quota-Tenant-Plan"] = decision.TenantPlan; + httpContext.Response.Headers["X-MCP-Quota-Tenant-Daily-Limit"] = decision.TenantDailyLimit.ToString(); + httpContext.Response.Headers["X-MCP-Quota-Tenant-Monthly-Limit"] = decision.TenantMonthlyLimit.ToString(); if (decision.DailyUsed >= 0) { httpContext.Response.Headers["X-MCP-Quota-Daily-Used"] = decision.DailyUsed.ToString(); httpContext.Response.Headers["X-MCP-Quota-Monthly-Used"] = decision.MonthlyUsed.ToString(); + httpContext.Response.Headers["X-MCP-Quota-Tenant-Daily-Used"] = decision.TenantDailyUsed.ToString(); + httpContext.Response.Headers["X-MCP-Quota-Tenant-Monthly-Used"] = decision.TenantMonthlyUsed.ToString(); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs index b4e38158..600760fa 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs @@ -72,6 +72,15 @@ namespace AutopilotMonitor.Functions.Security /// Fallback MCP monthly request limit when no SectionUsagePlans row matches the plan name. public int McpMonthlyRequestLimit { get; init; } + + /// + /// Organization-wide MCP daily limit all members of a tenant on this edition share (per-user + /// overrides never lift it). Fallback when the plan's SectionUsagePlans row sets no tenant limit. + /// + public int McpTenantDailyRequestLimit { get; init; } + + /// Organization-wide MCP monthly limit — see . + public int McpTenantMonthlyRequestLimit { get; init; } } /// @@ -116,7 +125,9 @@ namespace AutopilotMonitor.Functions.Security UnrestrictedModeAvailable = false, McpUsagePlanName = CommunityTierName, McpDailyRequestLimit = 100, - McpMonthlyRequestLimit = 3000 + McpMonthlyRequestLimit = 3000, + McpTenantDailyRequestLimit = 300, + McpTenantMonthlyRequestLimit = 9000 }; private static readonly EditionEntitlements Pro = new() @@ -130,7 +141,9 @@ namespace AutopilotMonitor.Functions.Security UnrestrictedModeAvailable = true, McpUsagePlanName = ProTierName, McpDailyRequestLimit = 1000, - McpMonthlyRequestLimit = 20000 + McpMonthlyRequestLimit = 20000, + McpTenantDailyRequestLimit = 3000, + McpTenantMonthlyRequestLimit = 60000 }; /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index 9c40e78f..2c9596e2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -552,13 +552,13 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Deletes usage tracking records older than 90 days from UserUsageLog. + /// Deletes usage tracking records older than 90 days from UserUsageLog and McpTenantUsage. /// private async Task CleanupOldUsageDataAsync() { + var cutoffDate = DateTime.UtcNow.AddDays(-90).ToString("yyyyMMdd"); try { - var cutoffDate = DateTime.UtcNow.AddDays(-90).ToString("yyyyMMdd"); var deleted = await _userUsageRepo.DeleteRecordsOlderThanAsync(cutoffDate); if (deleted > 0) @@ -569,6 +569,18 @@ namespace AutopilotMonitor.Functions.Services _logger.LogWarning(ex, "Failed to cleanup old usage data"); } + try + { + var deleted = await _userUsageRepo.DeleteTenantRecordsOlderThanAsync(cutoffDate); + + if (deleted > 0) + _logger.LogInformation("Tenant MCP usage cleanup: deleted {Count} counters older than 90 days (cutoff: {Cutoff})", deleted, cutoffDate); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to cleanup old tenant MCP usage counters"); + } + // Rule stats retention: delete entries older than 90 days try { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs index 0615f2f4..200c62b1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using AutopilotMonitor.Functions.Security; @@ -10,20 +11,27 @@ using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services { /// - /// Resolves and enforces the per-user MCP request quota (daily + monthly). + /// Resolves and enforces the MCP request quota: a per-USER budget (daily + monthly) AND a per-TENANT + /// budget (daily + monthly) that every member's requests count against. Both must be free for a + /// request to pass; the tenant budget is what makes "just create ten more accounts" pointless. /// - /// Plan precedence: explicit per-user override (McpUsers.UsagePlan — honoured only for the identity + /// User plan precedence: explicit per-user override (McpUsers.UsagePlan — honoured only for the identity /// the UPN is bound to, tid + oid, see McpUserService.GetBoundMcpUserAsync) → the caller's home-tenant - /// edition default (FeatureEntitlementCatalog.McpUsagePlanName). Limits come from the - /// admin-editable SectionUsagePlans definitions (AdminConfiguration.PlanTierDefinitionsJson); - /// when no definition matches the plan name, the static catalog fallbacks apply. An override - /// naming a plan that exists nowhere resolves to the Community fallback (fail-closed). + /// edition default (FeatureEntitlementCatalog.McpUsagePlanName). Limits come from the admin-editable + /// SectionUsagePlans definitions (AdminConfiguration.PlanTierDefinitionsJson); when no definition matches + /// the plan name, the static catalog fallbacks apply. An override naming a plan that exists nowhere + /// resolves to the Community fallback (fail-closed). /// - /// Counters ride on the existing UserUsage table (written fire-and-forget by - /// AuthenticationMiddleware for X-Client-Source: mcp requests): one partition-bounded query - /// per check covering the current month; daily = today's rows, monthly = the sum. The - /// decision is cached per instance for 60 seconds, so the worst-case overshoot is bounded - /// (limit + 60s × request-rate) — the same posture as the sliding-window rate limiter. + /// Tenant plan: ALWAYS the tenant's edition plan — a per-user override lifts that person's own budget, + /// never the organization's. A definition without tenant limits falls back to the edition's catalog + /// tenant limits; an explicit 0 lifts that window. + /// + /// Counters: user counters ride on the UserUsageLog table (PK = oid), tenant counters on the + /// McpTenantUsage table (PK = tenantId, RK = {yyyyMMdd}_{oid} — one partition read per check, no hot + /// row); both are written fire-and-forget by McpQuotaEnforcementMiddleware for X-Client-Source: mcp + /// requests. Daily = today's rows, monthly = the sum over the month. The decision is cached per user + /// for 60 seconds, so the worst-case overshoot is bounded (limit + 60s × request-rate) — the same + /// posture as the sliding-window rate limiter. /// public class McpQuotaService { @@ -68,9 +76,10 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Resolves the caller's effective plan + limits and checks the current usage against them. - /// Fail-open on counter/storage errors (a broken quota check must not take down MCP); - /// fail-closed on plan resolution (unknown plan → Community limits). + /// Resolves the caller's effective plans + limits and checks the current user AND tenant usage against + /// them. is the token's tid — a delegated (MSP) admin therefore draws on + /// their HOME tenant's budget, never on a managed customer's. Fail-open on counter/storage errors (a + /// broken quota check must not take down MCP); fail-closed on plan resolution (unknown plan → Community). /// public virtual async Task CheckAsync(string oid, string? upn, string? tenantId) { @@ -78,29 +87,36 @@ namespace AutopilotMonitor.Functions.Services if (_cache.TryGetValue(cacheKey, out var cached) && cached != null) return cached; - var (planName, dailyLimit, monthlyLimit) = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId); + var limits = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId); var nowUtc = _time.GetUtcNow().UtcDateTime; - long dailyUsed = 0, monthlyUsed = 0; + long dailyUsed = 0, monthlyUsed = 0, tenantDailyUsed = 0, tenantMonthlyUsed = 0; try { - var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc); + var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("yyyyMMdd"); var today = nowUtc.ToString("yyyyMMdd"); - var records = await _usageRepo.GetUsageByUserAsync( - oid, monthStart.ToString("yyyyMMdd"), today); + var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today); monthlyUsed = records.Sum(r => r.RequestCount); dailyUsed = records.Where(r => r.Date == today).Sum(r => r.RequestCount); + + // The tenant read is skipped when nothing could ever block on it (no tenant, or both windows lifted). + if (!string.IsNullOrWhiteSpace(tenantId) && (limits.TenantDailyLimit > 0 || limits.TenantMonthlyLimit > 0)) + { + var tenantRecords = await _usageRepo.GetTenantUsageAsync(tenantId, monthStart, today); + tenantMonthlyUsed = tenantRecords.Sum(r => r.RequestCount); + tenantDailyUsed = tenantRecords.Where(r => r.Date == today).Sum(r => r.RequestCount); + } } catch (Exception ex) { // Fail-open: usage counters unavailable → allow. Do NOT cache the fail-open // decision — the next request retries the read. _logger.LogWarning(ex, "[McpQuota] Usage lookup failed for oid={Oid} — allowing (fail-open)", oid); - return McpQuotaDecision.FailOpen(planName, dailyLimit, monthlyLimit); + return McpQuotaDecision.FailOpen(limits); } - var decision = BuildDecision(planName, dailyLimit, monthlyLimit, dailyUsed, monthlyUsed, nowUtc); + var decision = BuildDecision(limits, dailyUsed, monthlyUsed, tenantDailyUsed, tenantMonthlyUsed, nowUtc); _cache.Set(cacheKey, decision, CacheDuration); return decision; } @@ -109,9 +125,9 @@ namespace AutopilotMonitor.Functions.Services /// Plan resolution only (no counter read) — used by the self-service usage endpoint. /// is the caller's validated (upn, tid, oid); the per-user override applies /// only when that identity IS the one the McpUsers UPN is bound to — a null / unbound identity gets the - /// tenant edition default. + /// tenant edition default. The tenant limits always follow the tenant's edition plan. /// - public virtual async Task<(string PlanName, int DailyLimit, int MonthlyLimit)> ResolvePlanAsync(AdminIdentity? identity, string? tenantId) + public virtual async Task ResolvePlanAsync(AdminIdentity? identity, string? tenantId) { // 1. Per-user override wins when set — for the BOUND identity only. string? overridePlan = null; @@ -130,55 +146,96 @@ namespace AutopilotMonitor.Functions.Services // 2. Tenant edition default (fail-closed → Community inside the entitlement service). var entitlements = await _entitlementService.GetEntitlementsAsync(tenantId); - var planName = overridePlan ?? entitlements.McpUsagePlanName; + var tenantPlan = entitlements.McpUsagePlanName; + var planName = overridePlan ?? tenantPlan; - // 3. Limits: admin-edited SectionUsagePlans definition for that name, else catalog - // fallback for the edition plans, else Community fallback (fail-closed for - // overrides naming a plan that exists nowhere). + // 3. Limits: admin-edited SectionUsagePlans definitions, else catalog fallbacks. + var definitions = new List(); try { var adminConfig = await _adminConfigService.GetConfigurationAsync(); - var definition = PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson) - .FirstOrDefault(t => string.Equals(t.Name, planName, StringComparison.OrdinalIgnoreCase)); - if (definition != null) - return (planName, definition.DailyRequestLimit, definition.MonthlyRequestLimit); + definitions = PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson); } catch (Exception ex) { _logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback for plan {Plan}", planName); } - var fallback = FeatureEntitlementCatalog.IsPermanentProTier(planName) + // User limits: the definition for the (possibly overridden) plan name, else the catalog fallback + // for the edition plans, else Community (fail-closed for overrides naming a plan that exists nowhere). + var userDefinition = Find(definitions, planName); + var userFallback = FeatureEntitlementCatalog.IsPermanentProTier(planName) ? FeatureEntitlementCatalog.Get(TenantEdition.Pro) : FeatureEntitlementCatalog.Get(TenantEdition.Community); - return (planName, fallback.McpDailyRequestLimit, fallback.McpMonthlyRequestLimit); + var dailyLimit = userDefinition?.DailyRequestLimit ?? userFallback.McpDailyRequestLimit; + var monthlyLimit = userDefinition?.MonthlyRequestLimit ?? userFallback.McpMonthlyRequestLimit; + + // Tenant limits: the TENANT plan's definition when it carries tenant limits (null = not set → the + // edition's catalog tenant limits; an explicit 0 lifts the window), never the per-user override. + var tenantDefinition = Find(definitions, tenantPlan); + var tenantDailyLimit = tenantDefinition?.TenantDailyRequestLimit ?? entitlements.McpTenantDailyRequestLimit; + var tenantMonthlyLimit = tenantDefinition?.TenantMonthlyRequestLimit ?? entitlements.McpTenantMonthlyRequestLimit; + + return new McpPlanLimits(planName, dailyLimit, monthlyLimit, tenantPlan, tenantDailyLimit, tenantMonthlyLimit); } - internal static McpQuotaDecision BuildDecision( - string planName, int dailyLimit, int monthlyLimit, long dailyUsed, long monthlyUsed, DateTime nowUtc) - { - // Daily window resets at midnight UTC; monthly on the 1st. 0/negative limit = unlimited - // for that scope (an operator can deliberately lift a scope via SectionUsagePlans). - var dailyExceeded = dailyLimit > 0 && dailyUsed >= dailyLimit; - var monthlyExceeded = monthlyLimit > 0 && monthlyUsed >= monthlyLimit; + private static PlanTierDefinition? Find(List definitions, string planName) + => definitions.FirstOrDefault(t => string.Equals(t.Name, planName, StringComparison.OrdinalIgnoreCase)); - string? exceededScope = monthlyExceeded ? "monthly" : dailyExceeded ? "daily" : null; - var resetUtc = exceededScope == "monthly" + internal static McpQuotaDecision BuildDecision( + McpPlanLimits limits, + long dailyUsed, long monthlyUsed, long tenantDailyUsed, long tenantMonthlyUsed, + DateTime nowUtc) + { + // Daily windows reset at midnight UTC; monthly on the 1st. 0/negative limit = unlimited + // for that window (an operator can deliberately lift a window via SectionUsagePlans). + // Report the LONGEST exceeded window first so Retry-After is honest; within a window the + // caller's own budget is named before the organization's. + string? scope = null, level = null; + if (Exceeded(limits.MonthlyLimit, monthlyUsed)) (scope, level) = ("monthly", McpQuotaLevel.User); + else if (Exceeded(limits.TenantMonthlyLimit, tenantMonthlyUsed)) (scope, level) = ("monthly", McpQuotaLevel.Tenant); + else if (Exceeded(limits.DailyLimit, dailyUsed)) (scope, level) = ("daily", McpQuotaLevel.User); + else if (Exceeded(limits.TenantDailyLimit, tenantDailyUsed)) (scope, level) = ("daily", McpQuotaLevel.Tenant); + + var resetUtc = scope == "monthly" ? new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1) : nowUtc.Date.AddDays(1); return new McpQuotaDecision { - Allowed = exceededScope == null, - Plan = planName, - Scope = exceededScope, - DailyLimit = dailyLimit, - MonthlyLimit = monthlyLimit, + Allowed = scope == null, + Plan = limits.PlanName, + Scope = scope, + Level = level, + DailyLimit = limits.DailyLimit, + MonthlyLimit = limits.MonthlyLimit, DailyUsed = dailyUsed, MonthlyUsed = monthlyUsed, + TenantPlan = limits.TenantPlan, + TenantDailyLimit = limits.TenantDailyLimit, + TenantMonthlyLimit = limits.TenantMonthlyLimit, + TenantDailyUsed = tenantDailyUsed, + TenantMonthlyUsed = tenantMonthlyUsed, ResetUtc = resetUtc }; } + + private static bool Exceeded(int limit, long used) => limit > 0 && used >= limit; + } + + /// + /// Resolved plan names and limits for one caller: the user's plan (override or tenant edition) with the + /// user windows, and the tenant's edition plan with the organization-wide windows. 0 = unlimited. + /// + public sealed record McpPlanLimits( + string PlanName, int DailyLimit, int MonthlyLimit, + string TenantPlan, int TenantDailyLimit, int TenantMonthlyLimit); + + /// Whose budget a blocked decision names — wire vocabulary of level. + public static class McpQuotaLevel + { + public const string User = "user"; + public const string Tenant = "tenant"; } /// Outcome of an MCP quota check. @@ -188,21 +245,44 @@ namespace AutopilotMonitor.Functions.Services public string Plan { get; init; } = string.Empty; /// Which window was exceeded ("daily"/"monthly"), null when allowed. public string? Scope { get; init; } + /// Whose budget was exceeded (), null when allowed. + public string? Level { get; init; } public int DailyLimit { get; init; } public int MonthlyLimit { get; init; } public long DailyUsed { get; init; } public long MonthlyUsed { get; init; } + /// The tenant's edition plan — the organization-wide windows follow it, never the override. + public string TenantPlan { get; init; } = string.Empty; + public int TenantDailyLimit { get; init; } + public int TenantMonthlyLimit { get; init; } + public long TenantDailyUsed { get; init; } + public long TenantMonthlyUsed { get; init; } /// When the exceeded (or daily, when allowed) window resets. public DateTime ResetUtc { get; init; } - public static McpQuotaDecision FailOpen(string plan, int dailyLimit, int monthlyLimit) => new() + /// Limit of the exceeded window (0 when allowed). + public int ExceededLimit => Level == McpQuotaLevel.Tenant + ? (Scope == "monthly" ? TenantMonthlyLimit : TenantDailyLimit) + : (Scope == "monthly" ? MonthlyLimit : DailyLimit); + + /// Used count of the exceeded window. + public long ExceededUsed => Level == McpQuotaLevel.Tenant + ? (Scope == "monthly" ? TenantMonthlyUsed : TenantDailyUsed) + : (Scope == "monthly" ? MonthlyUsed : DailyUsed); + + public static McpQuotaDecision FailOpen(McpPlanLimits limits) => new() { Allowed = true, - Plan = plan, - DailyLimit = dailyLimit, - MonthlyLimit = monthlyLimit, + Plan = limits.PlanName, + DailyLimit = limits.DailyLimit, + MonthlyLimit = limits.MonthlyLimit, DailyUsed = -1, MonthlyUsed = -1, + TenantPlan = limits.TenantPlan, + TenantDailyLimit = limits.TenantDailyLimit, + TenantMonthlyLimit = limits.TenantMonthlyLimit, + TenantDailyUsed = -1, + TenantMonthlyUsed = -1, ResetUtc = DateTime.MinValue }; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index 0fedb3fa..dd60a4dc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -101,6 +101,8 @@ namespace AutopilotMonitor.Functions.Services.Offboarding // Intune script display-name cache (PK=tenantId). Only had TTL eviction before; // tenant script names must not outlive the tenant. Constants.TableNames.ScriptNameCache, + // Organization-wide MCP quota counters (PK=tenantId, RK={yyyyMMdd}_{oid}); also 90d retention. + Constants.TableNames.McpTenantUsage, }; // Variant D — RowKey-anchored wipes for tables whose ROW key is the tenant id. diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/error-handler.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/error-handler.test.ts index 72055295..0e3ad276 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/error-handler.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/error-handler.test.ts @@ -57,4 +57,46 @@ describe('toolError', () => { expect(text).toContain('cid-9'); expect(text).toContain('E500'); }); + + it('renders the backend MCP quota 429 body instead of "undefined"', () => { + // McpQuotaExceededResponse carries no `error` key — before this branch the structured-4xx path + // printed "Error in …: undefined" and the model retried into the same wall. + const err = new ApiError(429, JSON.stringify({ + quotaExceeded: true, + plan: 'community', + scope: 'daily', + level: 'user', + limit: 100, + used: 100, + resetUtc: '2026-09-03T00:00:00Z', + message: "MCP daily request quota exceeded for plan 'community'. Resets at 2026-09-03T00:00:00Z.", + })); + const res = toolError('search_sessions', {}, err); + const text = res.content[0].text; + expect(text).toContain('Quota exceeded in search_sessions'); + expect(text).toContain("quota exceeded for plan 'community'"); + expect(text).toContain('100 of 100 requests used'); + expect(text).toContain('2026-09-03T00:00:00Z'); + expect(text).toContain('larger usage plan'); + expect(text).not.toContain('undefined'); + }); + + it('names the organization when the tenant-wide quota is exhausted', () => { + const err = new ApiError(429, JSON.stringify({ + quotaExceeded: true, + plan: 'power', + scope: 'monthly', + level: 'tenant', + limit: 60000, + used: 60000, + resetUtc: '2026-10-01T00:00:00Z', + message: "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-10-01T00:00:00Z.", + })); + const res = toolError('get_session', {}, err); + const text = res.content[0].text; + expect(text).toContain('of your organization exceeded'); + expect(text).toContain('tenant level'); + expect(text).toContain('shared by every member of the tenant'); + expect(text).not.toContain('larger usage plan'); + }); }); 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 99ae115a..f30d527e 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 @@ -2226,6 +2226,8 @@ export interface McpQuotaExceededResponse { plan: string; /** Which window was exceeded ("daily"/"monthly") — always set on the blocked path. */ scope?: string; + /** Whose budget was exceeded: "user" (the caller's own plan) or "tenant" (the organization-wide windows). */ + level: string; /** Limit of the exceeded window. */ limit: number; /** Used count of the exceeded window. */ @@ -2235,13 +2237,18 @@ export interface McpQuotaExceededResponse { message: string; } -/** Effective quota state nested in GetMyMcpUsageResponse. */ +/** Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their tenant (shared by every member; 0 = unlimited). */ export interface McpUsageQuotaNode { dailyLimit: number; monthlyLimit: number; dailyUsed: number; monthlyUsed: number; resetUtc: string; + tenantPlan: string; + tenantDailyLimit: number; + tenantMonthlyLimit: number; + tenantDailyUsed: number; + tenantMonthlyUsed: number; } export interface McpUserEntry { @@ -2348,12 +2355,16 @@ export interface PerformanceMetrics { clampedSessionCount: number; } -/** Defines a usage plan tier with request limits. Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson. */ +/** Defines a usage plan tier with request limits: the per-USER windows every account on the plan gets, and the organization-wide TENANT windows all members of a tenant on this plan share. Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson. 0 = unlimited for that window. */ export interface PlanTierDefinition { name: string; dailyRequestLimit: number; monthlyRequestLimit: number; description: string; + /** Tenant-wide daily limit; null (not set) = the edition's catalog tenant limit, 0 = unlimited. */ + tenantDailyRequestLimit?: number; + /** Tenant-wide monthly limit; null (not set) = the edition's catalog tenant limit, 0 = unlimited. */ + tenantMonthlyRequestLimit?: number; } /** Response of GET and PUT global/config/plan-tiers: the global usage-plan tier definitions. */ diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts index 8dfbd1d2..15f18dba 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts @@ -35,6 +35,21 @@ export function toolError( if (error.parsed?.correlationId) parts.push(`**Correlation ID**: ${error.parsed.correlationId}`); if (error.parsed?.errorCode) parts.push(`**Error code**: ${error.parsed.errorCode}`); parts.push('**Suggestion**: retry in a few seconds; if persistent, ask an operator to inspect backend logs.'); + } else if (error instanceof ApiError && error.status === 429 && error.parsed?.quotaExceeded === true) { + // Backend MCP quota (McpQuotaExceededResponse): the daily/monthly budget of the caller's own plan + // (level=user) or of the whole organization (level=tenant). Retrying is pointless until resetUtc — + // say so, and say WHOSE budget it is, so a member blocked by the tenant window does not go and + // create more accounts or ask for a bigger personal plan. + const p = error.parsed; + const whose = p.level === 'tenant' ? "your organization's" : 'your'; + parts.push(`**Quota exceeded in ${toolName}**: ${p.message ?? `${whose} MCP ${p.scope ?? ''} request quota is exhausted.`}`); + if (p.limit != null && p.used != null) parts.push(`**Budget**: ${p.used} of ${p.limit} requests used (${p.scope ?? 'window'}, ${p.level ?? 'user'} level).`); + if (p.resetUtc) parts.push(`**Resets at**: ${p.resetUtc}`); + parts.push( + p.level === 'tenant' + ? '**Suggestion**: do not retry before the reset — the window is shared by every member of the tenant; a tenant admin can review consumption under Configuration → Reporting → MCP Usage.' + : '**Suggestion**: do not retry before the reset; narrow further queries, or ask an administrator about a larger usage plan.', + ); } else if (error instanceof ApiError && error.parsed) { // Structured backend error (4xx) — extract rich details const p = error.parsed; diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index a802f206..a99f6040 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -1117,6 +1117,10 @@ namespace AutopilotMonitor.Shared // User usage tracking (per-user, per-day, per-endpoint) public const string UserUsageLog = "UserUsageLog"; + // MCP tenant usage counters for the organization-wide quota (PK = tenantId, + // RK = {yyyyMMdd}_{oid}): one partition read per quota check, no hot row. + public const string McpTenantUsage = "McpTenantUsage"; + // Operational events (vital infrastructure signals for Global Admin Ops dashboard) public const string OpsEvents = "OpsEvents"; @@ -1270,6 +1274,7 @@ namespace AutopilotMonitor.Shared CveIndex, DistressReports, UserUsageLog, + McpTenantUsage, OpsEvents, ImeVersionHistory, ImePatternStats, diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IUserUsageRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IUserUsageRepository.cs index 9a4c9f6b..19a09d9c 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IUserUsageRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IUserUsageRepository.cs @@ -36,6 +36,27 @@ namespace AutopilotMonitor.Shared.DataAccess /// Returns the number of records deleted. /// Task DeleteRecordsOlderThanAsync(string dateCutoff); + + /// + /// Increments the tenant-wide MCP counter for (tenant, user, today) — the organization-wide + /// quota's counter, kept per user so the tenant partition has no hot row. + /// + Task IncrementTenantUsageAsync(string tenantId, string userId); + + /// Tenant-wide MCP counters (one row per user and day) within an optional yyyyMMdd range. + Task> GetTenantUsageAsync(string tenantId, string? dateFrom = null, string? dateTo = null); + + /// Deletes tenant-wide MCP counters older than the specified date (yyyyMMdd). Returns the count deleted. + Task DeleteTenantRecordsOlderThanAsync(string dateCutoff); + } + + /// One tenant-wide MCP counter row: one tenant, one user, one day. + public class TenantUsageRecord + { + public string TenantId { get; set; } = string.Empty; + public string UserId { get; set; } = string.Empty; + public string Date { get; set; } = string.Empty; + public long RequestCount { get; set; } } /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinition.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinition.cs index 546d65f0..9d6db3be 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinition.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinition.cs @@ -1,8 +1,9 @@ namespace AutopilotMonitor.Shared.Models.Config { /// - /// Defines a usage plan tier with request limits. - /// Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson. + /// Defines a usage plan tier with request limits: the per-USER windows every account on the plan gets, + /// and the organization-wide TENANT windows all members of a tenant on this plan share. + /// Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson. 0 = unlimited for that window. /// public class PlanTierDefinition { @@ -10,5 +11,11 @@ namespace AutopilotMonitor.Shared.Models.Config public int DailyRequestLimit { get; set; } = 100; public int MonthlyRequestLimit { get; set; } = 3000; public string Description { get; set; } = string.Empty; + + /// Tenant-wide daily limit; null (not set) = the edition's catalog tenant limit, 0 = unlimited. + public int? TenantDailyRequestLimit { get; set; } + + /// Tenant-wide monthly limit; null (not set) = the edition's catalog tenant limit, 0 = unlimited. + public int? TenantMonthlyRequestLimit { get; set; } } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs index e2959b74..b45f0316 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs @@ -224,6 +224,9 @@ namespace AutopilotMonitor.Shared.Models /// Which window was exceeded ("daily"/"monthly") — always set on the blocked path. public string? Scope { get; set; } + /// Whose budget was exceeded: "user" (the caller's own plan) or "tenant" (the organization-wide windows). + public string Level { get; set; } = default!; + /// Limit of the exceeded window. public int Limit { get; set; } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs index 9997fc38..ff47e1c1 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/MetricsApiModels.cs @@ -91,7 +91,10 @@ namespace AutopilotMonitor.Shared.Models public IReadOnlyList Records { get; set; } = default!; } - /// Effective quota state nested in . + /// + /// Effective quota state nested in : the caller's own windows and + /// the organization-wide windows of their tenant (shared by every member; 0 = unlimited). + /// // Declaration order == wire order. public class McpUsageQuotaNode { @@ -100,6 +103,11 @@ namespace AutopilotMonitor.Shared.Models public long DailyUsed { get; set; } public long MonthlyUsed { get; set; } public DateTime ResetUtc { get; set; } + public string TenantPlan { get; set; } = default!; + public int TenantDailyLimit { get; set; } + public int TenantMonthlyLimit { get; set; } + public long TenantDailyUsed { get; set; } + public long TenantMonthlyUsed { get; set; } } /// diff --git a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx index 8a9139d2..1fe02444 100644 --- a/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/settings/sections/SectionUsagePlans.tsx @@ -12,6 +12,10 @@ interface PlanTierDefinition { dailyRequestLimit: number; monthlyRequestLimit: number; description: string; + // Organization-wide windows shared by every member of a tenant on this plan. Absent = the edition's + // built-in tenant limit applies; 0 = unlimited. Per-user plan overrides never lift these. + tenantDailyRequestLimit?: number; + tenantMonthlyRequestLimit?: number; } const DEFAULT_TIER: PlanTierDefinition = { @@ -21,6 +25,13 @@ const DEFAULT_TIER: PlanTierDefinition = { description: "", }; +// Blank input = "not set" (undefined on the wire → catalog fallback); "0" stays 0 (= unlimited). +function parseOptionalLimit(raw: string): number | undefined { + if (raw.trim() === "") return undefined; + const n = parseInt(raw, 10); + return Number.isNaN(n) ? undefined : Math.max(0, n); +} + export function SectionUsagePlans() { const { getAccessToken } = useAuth(); const { ensureAdminConfigLoaded, ensureTenantsLoaded, tenants, userRateLimit } = useAdminConfig(); @@ -107,7 +118,7 @@ export function SectionUsagePlans() { } }, [tiers, getAccessToken]); - const updateTier = (index: number, field: keyof PlanTierDefinition, value: string | number) => { + const updateTier = (index: number, field: keyof PlanTierDefinition, value: string | number | undefined) => { setTiers(prev => { const updated = [...prev]; updated[index] = { ...updated[index], [field]: value }; @@ -168,11 +179,16 @@ export function SectionUsagePlans() { {/* Tenant Default Info */} {tenants.length > 0 && ( -
-

+

+

Users without a per-user plan override inherit their tenant's effective user rate limit ({tenants[0]?.customUserRateLimitRequestsPerMinute ?? userRateLimit} req/min).

+

+ The per-user limits cap each account; the tenant limits cap the whole organization (all members + together) and always follow the tenant's own plan, so a per-user override never widens them. + Leave a tenant limit blank for the built-in default, 0 for unlimited. +

)} @@ -197,7 +213,7 @@ export function SectionUsagePlans() { {tiers.map((tier, index) => (
-
+
{/* Name */}
@@ -231,6 +247,30 @@ export function SectionUsagePlans() { className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500" />
+ {/* Tenant Daily Limit */} +
+ + updateTier(index, "tenantDailyRequestLimit", parseOptionalLimit(e.target.value))} + placeholder="default" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
+ {/* Tenant Monthly Limit */} +
+ + updateTier(index, "tenantMonthlyRequestLimit", parseOptionalLimit(e.target.value))} + placeholder="default" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500" + /> +
{/* Description */}
diff --git a/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx b/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx index 8c3d5983..88facea3 100644 --- a/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/reporting/sections/SectionMcpUsage.tsx @@ -22,6 +22,42 @@ interface DailyAggregate { endpoints: number; } +// GetMyMcpUsageResponse.quota — the caller's own windows plus the organization-wide windows every +// member shares. 0 = unlimited; -1 used = counters temporarily unavailable. +interface QuotaState { + dailyLimit: number; + monthlyLimit: number; + dailyUsed: number; + monthlyUsed: number; + tenantPlan: string; + tenantDailyLimit: number; + tenantMonthlyLimit: number; + tenantDailyUsed: number; + tenantMonthlyUsed: number; +} + +function QuotaBar({ label, used, limit }: { label: string; used: number; limit: number }) { + const unavailable = used < 0; + const unlimited = limit <= 0; + const pct = unavailable || unlimited ? 0 : Math.min(100, Math.round((used / limit) * 100)); + const tone = pct >= 100 ? "bg-red-500" : pct >= 80 ? "bg-amber-500" : "bg-indigo-500"; + return ( +
+
+ {label} + + {unavailable ? "n/a" : used.toLocaleString()} + {" / "} + {unlimited ? "unlimited" : limit.toLocaleString()} + +
+
+
+
+
+ ); +} + type DateRange = "7d" | "30d" | "90d"; function formatDate(yyyymmdd: string): string { @@ -44,6 +80,8 @@ export function SectionMcpUsage() { const { getAccessToken } = useAuth(); const [records, setRecords] = useState([]); const [usagePlan, setUsagePlan] = useState(null); + const [effectivePlan, setEffectivePlan] = useState(null); + const [quota, setQuota] = useState(null); const [upn, setUpn] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -63,6 +101,8 @@ export function SectionMcpUsage() { const data = await res.json(); setRecords(data.records || []); setUsagePlan(data.usagePlan || null); + setEffectivePlan(data.effectivePlan || null); + setQuota(data.quota ?? null); setUpn(data.upn || ""); } catch (err) { if (err instanceof TokenExpiredError) { @@ -121,7 +161,7 @@ export function SectionMcpUsage() { )} {!usagePlan && ( - Plan: inherited + Plan: {effectivePlan ? `${effectivePlan} (inherited)` : "inherited"} )} {/* Date Range Selector */} @@ -146,6 +186,32 @@ export function SectionMcpUsage() {
)} + {/* Quota: the caller's own windows and the organization-wide windows shared by every member */} + {quota && ( +
+
+
+

Your quota

+ plan {effectivePlan ?? "—"} +
+ + +
+
+
+

Organization quota

+ tenant plan {quota.tenantPlan} +
+ + +

+ Shared by every account in your tenant. A personal plan override widens only the account's own + windows, never these. +

+
+
+ )} + {/* Summary Cards */}
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 055fc604..fb39e08a 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -10593,6 +10593,14 @@ "optional": true, "doc": "Which window was exceeded (\"daily\"/\"monthly\") — always set on the blocked path." }, + { + "name": "level", + "type": { + "kind": "primitive", + "name": "string" + }, + "doc": "Whose budget was exceeded: \"user\" (the caller's own plan) or \"tenant\" (the organization-wide windows)." + }, { "name": "limit", "type": { @@ -10628,7 +10636,7 @@ }, "McpUsageQuotaNode": { "kind": "object", - "doc": "Effective quota state nested in GetMyMcpUsageResponse.", + "doc": "Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their tenant (shared by every member; 0 = unlimited).", "fields": [ { "name": "dailyLimit", @@ -10664,6 +10672,41 @@ "kind": "primitive", "name": "string" } + }, + { + "name": "tenantPlan", + "type": { + "kind": "primitive", + "name": "string" + } + }, + { + "name": "tenantDailyLimit", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "tenantMonthlyLimit", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "tenantDailyUsed", + "type": { + "kind": "primitive", + "name": "number" + } + }, + { + "name": "tenantMonthlyUsed", + "type": { + "kind": "primitive", + "name": "number" + } } ] }, @@ -11164,7 +11207,7 @@ }, "PlanTierDefinition": { "kind": "object", - "doc": "Defines a usage plan tier with request limits. Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson.", + "doc": "Defines a usage plan tier with request limits: the per-USER windows every account on the plan gets, and the organization-wide TENANT windows all members of a tenant on this plan share. Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson. 0 = unlimited for that window.", "fields": [ { "name": "name", @@ -11193,6 +11236,24 @@ "kind": "primitive", "name": "string" } + }, + { + "name": "tenantDailyRequestLimit", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Tenant-wide daily limit; null (not set) = the edition's catalog tenant limit, 0 = unlimited." + }, + { + "name": "tenantMonthlyRequestLimit", + "type": { + "kind": "primitive", + "name": "number" + }, + "optional": true, + "doc": "Tenant-wide monthly limit; null (not set) = the edition's catalog tenant limit, 0 = unlimited." } ] }, 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 8d53f558..fed9381a 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -2226,6 +2226,8 @@ export interface McpQuotaExceededResponse { plan: string; /** Which window was exceeded ("daily"/"monthly") — always set on the blocked path. */ scope?: string; + /** Whose budget was exceeded: "user" (the caller's own plan) or "tenant" (the organization-wide windows). */ + level: string; /** Limit of the exceeded window. */ limit: number; /** Used count of the exceeded window. */ @@ -2235,13 +2237,18 @@ export interface McpQuotaExceededResponse { message: string; } -/** Effective quota state nested in GetMyMcpUsageResponse. */ +/** Effective quota state nested in GetMyMcpUsageResponse: the caller's own windows and the organization-wide windows of their tenant (shared by every member; 0 = unlimited). */ export interface McpUsageQuotaNode { dailyLimit: number; monthlyLimit: number; dailyUsed: number; monthlyUsed: number; resetUtc: string; + tenantPlan: string; + tenantDailyLimit: number; + tenantMonthlyLimit: number; + tenantDailyUsed: number; + tenantMonthlyUsed: number; } export interface McpUserEntry { @@ -2348,12 +2355,16 @@ export interface PerformanceMetrics { clampedSessionCount: number; } -/** Defines a usage plan tier with request limits. Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson. */ +/** Defines a usage plan tier with request limits: the per-USER windows every account on the plan gets, and the organization-wide TENANT windows all members of a tenant on this plan share. Stored as JSON array in AdminConfiguration.PlanTierDefinitionsJson. 0 = unlimited for that window. */ export interface PlanTierDefinition { name: string; dailyRequestLimit: number; monthlyRequestLimit: number; description: string; + /** Tenant-wide daily limit; null (not set) = the edition's catalog tenant limit, 0 = unlimited. */ + tenantDailyRequestLimit?: number; + /** Tenant-wide monthly limit; null (not set) = the edition's catalog tenant limit, 0 = unlimited. */ + tenantMonthlyRequestLimit?: number; } /** Response of GET and PUT global/config/plan-tiers: the global usage-plan tier definitions. */