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 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-02 10:32:15 +02:00
parent 4b8b2a5a6d
commit eb47dfb6ce
25 changed files with 884 additions and 146 deletions
@@ -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,
},
};
@@ -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]
@@ -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",
@@ -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<IUserUsageRepository> UsageRepo(params (string Date, long Count)[] rows)
=> UsageRepo(rows, tenantRows: Array.Empty<(string, long)>());
private static Mock<IUserUsageRepository> UsageRepo((string Date, long Count)[] rows, (string Date, long Count)[] tenantRows)
{
var repo = new Mock<IUserUsageRepository>();
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
@@ -114,6 +171,14 @@ public class McpQuotaServiceTests
Date = r.Date,
RequestCount = r.Count
}).ToList());
repo.Setup(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()))
.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<string>(), It.IsAny<string?>(), It.IsAny<string?>()), 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<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task Check_TenantLookupThrows_FailsOpen_AndDoesNotCache()
{
var repo = UsageRepo(("20260707", 1));
repo.Setup(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
.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<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
}
}
@@ -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
});
@@ -11,11 +11,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// 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).
/// </summary>
public class TableUserUsageRepository : IUserUsageRepository
{
private readonly TableClient _tableClient;
private readonly TableClient _tenantTableClient;
private readonly ILogger<TableUserUsageRepository> _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<TableEntity>(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<List<TenantUsageRecord>> GetTenantUsageAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
{
var records = new List<TenantUsageRecord>();
if (string.IsNullOrWhiteSpace(tenantId))
return records;
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(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;
}
/// <summary>
/// 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.
/// </summary>
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<int> 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<TableEntity>(
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
@@ -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
});
@@ -12,7 +12,8 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Middleware;
/// <summary>
/// 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
/// <see cref="UserRateLimitMiddleware"/> (per-minute burst control) — this is the budget layer on
/// top. Applies ONLY to HTTP requests marked <c>X-Client-Source: mcp</c> 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));
}
/// <summary>
/// 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.
/// </summary>
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
};
}
/// <summary>
@@ -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();
}
}
}
@@ -72,6 +72,15 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>Fallback MCP monthly request limit when no SectionUsagePlans row matches the plan name.</summary>
public int McpMonthlyRequestLimit { get; init; }
/// <summary>
/// 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.
/// </summary>
public int McpTenantDailyRequestLimit { get; init; }
/// <summary>Organization-wide MCP monthly limit — see <see cref="McpTenantDailyRequestLimit"/>.</summary>
public int McpTenantMonthlyRequestLimit { get; init; }
}
/// <summary>
@@ -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
};
/// <summary>
@@ -552,13 +552,13 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// Deletes usage tracking records older than 90 days from UserUsageLog.
/// Deletes usage tracking records older than 90 days from UserUsageLog and McpTenantUsage.
/// </summary>
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
{
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class McpQuotaService
{
@@ -68,9 +76,10 @@ namespace AutopilotMonitor.Functions.Services
}
/// <summary>
/// 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. <paramref name="tenantId"/> 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).
/// </summary>
public virtual async Task<McpQuotaDecision> CheckAsync(string oid, string? upn, string? tenantId)
{
@@ -78,29 +87,36 @@ namespace AutopilotMonitor.Functions.Services
if (_cache.TryGetValue<McpQuotaDecision>(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.
/// <paramref name="identity"/> 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.
/// </summary>
public virtual async Task<(string PlanName, int DailyLimit, int MonthlyLimit)> ResolvePlanAsync(AdminIdentity? identity, string? tenantId)
public virtual async Task<McpPlanLimits> 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<PlanTierDefinition>();
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<PlanTierDefinition> 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;
}
/// <summary>
/// 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.
/// </summary>
public sealed record McpPlanLimits(
string PlanName, int DailyLimit, int MonthlyLimit,
string TenantPlan, int TenantDailyLimit, int TenantMonthlyLimit);
/// <summary>Whose budget a blocked decision names — wire vocabulary of <c>level</c>.</summary>
public static class McpQuotaLevel
{
public const string User = "user";
public const string Tenant = "tenant";
}
/// <summary>Outcome of an MCP quota check.</summary>
@@ -188,21 +245,44 @@ namespace AutopilotMonitor.Functions.Services
public string Plan { get; init; } = string.Empty;
/// <summary>Which window was exceeded ("daily"/"monthly"), null when allowed.</summary>
public string? Scope { get; init; }
/// <summary>Whose budget was exceeded (<see cref="McpQuotaLevel"/>), null when allowed.</summary>
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; }
/// <summary>The tenant's edition plan — the organization-wide windows follow it, never the override.</summary>
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; }
/// <summary>When the exceeded (or daily, when allowed) window resets.</summary>
public DateTime ResetUtc { get; init; }
public static McpQuotaDecision FailOpen(string plan, int dailyLimit, int monthlyLimit) => new()
/// <summary>Limit of the exceeded window (0 when allowed).</summary>
public int ExceededLimit => Level == McpQuotaLevel.Tenant
? (Scope == "monthly" ? TenantMonthlyLimit : TenantDailyLimit)
: (Scope == "monthly" ? MonthlyLimit : DailyLimit);
/// <summary>Used count of the exceeded window.</summary>
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
};
}
@@ -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.