feat(plans): tenant-wide MCP usage-plan override + Community upgrade hint on quota 429

- TenantConfiguration.McpUsagePlanOverride: a SectionUsagePlans plan name pinned to a whole
  tenant (members' default user plan + organization windows); precedence per-user override >
  tenant override > edition default; never changes the edition
- PATCH config/{tenantId}/plan accepts mcpUsagePlan (validated against the definitions,
  400 on unknown names; null/blank clears), audited, echoed as mcpUsagePlan/mcpUsagePlanOverride
- Tenant Management: "MCP usage plan (override)" select fed by global/config/plan-tiers
- Quota 429: every Community block now says Community is sized for occasional use and Pro
  lifts the window; managed-tenant block names whose plan governs it
- Field in BaseDeniedFields/RevertProtectedFields/projection, Store/Map, manifests regenerated

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