fix(web,backend): MCP Usage under a tenant override shows only that tenant (D-241 amended)

- GetMcpOrganizationUsageResponse.daily: charged requests per day inside the range from the same McpTenantUsage rows (BuildOrganizationDaily, MSP reads included); manifest + wire types regenerated (web + MCP copy); fold test
- Web: viewing another tenant no longer fetches the caller's own usage — no UPN, plan badge or own quota; the summary tiles and the daily chart come from the tenant's daily series. The own tenant stays the caller's usage page. Internal gitlink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-10 03:38:58 +02:00
parent 6804bcfd4e
commit 2b4dbe087a
8 changed files with 139 additions and 39 deletions
@@ -110,6 +110,23 @@ public class McpOrganizationUsageAggregationTests
Assert.Equal(27, quota.MonthlyUsed);
}
[Fact]
public void OrganizationDaily_SumsEveryAccountPerDay_InsideTheRange_OldestFirst()
{
var rows = new[]
{
Row(Member, "20260815", 100), // before the range
Row(Member, "20260902", 2),
Row(MspAdmin, "20260902", 5, home: MspHome), // same day, delegated — one bar
Row(Member, "20260901", 20),
Row(Member, "20260903", 7), // after the range (read window)
};
var daily = McpUsageMetricsFunction.BuildOrganizationDaily(rows, "20260901", "20260902");
Assert.Equal(new[] { ("20260901", 20L), ("20260902", 7L) }, daily.Select(d => (d.Date, d.Requests)));
}
[Fact]
public void OrganizationQuota_LiftedWindows_StillReportTheCounters()
{
@@ -160,6 +160,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
DateTo = dateTo,
Users = AggregateOrganizationUsage(records, tenantId, dateFrom, dateTo, today, monthStart),
Quota = BuildOrganizationQuota(records, limits, today, monthStart),
Daily = BuildOrganizationDaily(records, dateFrom, dateTo),
});
return response;
}
@@ -169,6 +170,21 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
}
}
/// <summary>Pure: requests charged to the tenant per day inside [dateFrom, dateTo], oldest first.</summary>
internal static List<McpOrganizationDailyItem> BuildOrganizationDaily(
IEnumerable<TenantUsageRecord> records, string dateFrom, string dateTo)
{
var byDay = new SortedDictionary<string, long>(StringComparer.Ordinal);
foreach (var record in records)
{
if (string.CompareOrdinal(record.Date, dateFrom) < 0 || string.CompareOrdinal(record.Date, dateTo) > 0)
continue;
byDay[record.Date] = byDay.TryGetValue(record.Date, out var sum) ? sum + record.RequestCount : record.RequestCount;
}
return byDay.Select(kv => new McpOrganizationDailyItem { Date = kv.Key, Requests = kv.Value }).ToList();
}
/// <summary>
/// Pure: the tenant's organization windows from the same counter rows — today's and this month's total over
/// every account charged to the tenant, against the tenant plan's limits (0 = unlimited).
@@ -2023,6 +2023,8 @@ export interface GetMcpOrganizationUsageResponse {
users: McpOrganizationUsageItem[];
/** The tenant's organization windows (plan, limits, today / this month) from the same counters. */
quota: McpOrganizationQuotaNode;
/** Requests charged to the tenant per day inside the range, oldest first; days without requests are absent. */
daily: McpOrganizationDailyItem[];
}
/** Per-user MCP/API usage envelope (GetMcpUserUsage). Non-global callers only receive the records attributed to their own tenant; a foreign oid and an unknown oid are indistinguishable (both 200 with empty records). The MCP server paginates over the records key — its name is wire-critical. */
@@ -2824,6 +2826,13 @@ export interface McpHealthCheckResponse {
check: HealthCheck;
}
/** One day of the tenant's organization budget, nested in GetMcpOrganizationUsageResponse. */
export interface McpOrganizationDailyItem {
/** yyyyMMdd. */
date: string;
requests: number;
}
/** The organization-wide windows of the tenant in GetMcpOrganizationUsageResponse: its tenant plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every account charged to the tenant. */
export interface McpOrganizationQuotaNode {
tenantPlan: string;
@@ -143,6 +143,17 @@ namespace AutopilotMonitor.Shared.Models
public IReadOnlyList<McpOrganizationUsageItem> Users { get; set; } = default!;
/// <summary>The tenant's organization windows (plan, limits, today / this month) from the same counters.</summary>
public McpOrganizationQuotaNode Quota { get; set; } = default!;
/// <summary>Requests charged to the tenant per day inside the range, oldest first; days without requests are absent.</summary>
public IReadOnlyList<McpOrganizationDailyItem> Daily { get; set; } = default!;
}
/// <summary>One day of the tenant's organization budget, nested in <see cref="GetMcpOrganizationUsageResponse"/>.</summary>
// Declaration order == wire order.
public class McpOrganizationDailyItem
{
/// <summary>yyyyMMdd.</summary>
public string Date { get; set; } = default!;
public long Requests { get; set; }
}
/// <summary>
@@ -27,10 +27,10 @@ interface UsageRecord {
lastRequestAt: string;
}
/** One bar of the daily chart — the caller's own requests, or the selected tenant's charged requests. */
interface DailyAggregate {
date: string;
totalRequests: number;
endpoints: number;
}
// GetMyMcpUsageResponse.quota — the caller's own windows plus the organization-wide windows every
@@ -83,20 +83,33 @@ function getDateTo(): string {
return new Date().toISOString().slice(0, 10).replace(/-/g, "");
}
/** The caller's own per-endpoint records folded to one bar per day, newest first. */
function aggregateOwnRecords(records: UsageRecord[]): DailyAggregate[] {
const byDate = new Map<string, number>();
for (const r of records) byDate.set(r.date, (byDate.get(r.date) ?? 0) + r.requestCount);
return Array.from(byDate.entries())
.map(([date, totalRequests]) => ({ date, totalRequests }))
.sort((a, b) => b.date.localeCompare(a.date));
}
export function SectionMcpUsage() {
const { getAccessToken, user } = useAuth();
const canSeeOrganization = !!(user?.isTenantAdmin || user?.isGlobalAdmin);
// Global-admin tenant scope from the reporting header (override-only: always a concrete tenant,
// defaulting to the caller's own). Only the organization cards follow the selection — the caller's own
// quota and request history never do. A delegated (MSP) caller keeps the member path: the organization
// route never lists a managed tenant's accounts (the header shows them no selector).
// defaulting to the caller's own). Viewing ANOTHER tenant is a tenant view: nothing of the caller's
// own account is shown — no UPN, no plan badge, no own quota — and the tiles and the daily chart
// come from the tenant's organization counters. On the own tenant the page stays the caller's usage
// page. A delegated (MSP) caller keeps the member path: the organization route never lists a
// managed tenant's accounts (the header shows them no selector).
const scope = useReportingScope();
const crossTenant = scope.routeGlobal && !scope.isDelegatedScope;
const { effectiveTenantId, isGlobalOverride } = scope;
const { effectiveTenantId, isGlobalOverride: tenantView } = scope;
const tenantLabel = scope.tenants.find((t) => t.tenantId === effectiveTenantId)?.domainName ?? effectiveTenantId;
const [records, setRecords] = useState<UsageRecord[]>([]);
// Organization budget by account + the tenant's windows — tenant admins / GA only; null = not loaded / not permitted.
// Organization budget by account, the tenant's windows and its daily series — tenant admins / GA only;
// null = not loaded / not permitted.
const [orgUsage, setOrgUsage] = useState<GetMcpOrganizationUsageResponse | null>(null);
const [usagePlan, setUsagePlan] = useState<string | null>(null);
const [effectivePlan, setEffectivePlan] = useState<string | null>(null);
@@ -121,8 +134,11 @@ export function SectionMcpUsage() {
const dateFrom = getDateFrom(range);
const dateTo = getDateTo();
const orgSelection = { routeGlobal: crossTenant, selectedTenantId: effectiveTenantId, effectiveTenantId };
const [data, org] = await Promise.all([
fetchJson<GetMyMcpUsageResponse>(api.mcpUsage.me(dateFrom, dateTo), getAccessToken),
const [own, org] = await Promise.all([
// The caller's own usage has no place in another tenant's view.
tenantView
? Promise.resolve(null)
: fetchJson<GetMyMcpUsageResponse>(api.mcpUsage.me(dateFrom, dateTo), getAccessToken),
canSeeOrganization
? // Every account charged to the tenant's organization budget — including delegated (MSP)
// administrators reading it. A 403 (role changed mid-session) just hides the card.
@@ -136,11 +152,11 @@ export function SectionMcpUsage() {
: Promise.resolve(null),
]);
if (!isCurrent()) return;
setRecords(data.records || []);
setUsagePlan(data.usagePlan || null);
setEffectivePlan(data.effectivePlan || null);
setQuota(data.quota ?? null);
setUpn(data.upn || "");
setRecords(own?.records ?? []);
setUsagePlan(own?.usagePlan || null);
setEffectivePlan(own?.effectivePlan || null);
setQuota(own?.quota ?? null);
setUpn(own?.upn || "");
setOrgUsage(org);
} catch (err) {
if (!isCurrent()) return;
@@ -148,7 +164,7 @@ export function SectionMcpUsage() {
} finally {
if (isCurrent()) setLoading(false);
}
}, [getAccessToken, canSeeOrganization, crossTenant, effectiveTenantId]);
}, [getAccessToken, canSeeOrganization, crossTenant, effectiveTenantId, tenantView]);
useEffect(() => {
const run = async () => {
@@ -176,26 +192,14 @@ export function SectionMcpUsage() {
: null);
// Wording follows the viewpoint: the caller's own tenant, or the tenant a global admin picked.
const tenantNoun = isGlobalOverride ? "this tenant" : "your tenant";
const budgetNoun = isGlobalOverride ? "this tenant's organization budget" : "your organization budget";
const planNoun = isGlobalOverride ? "its plan" : "your plan";
const tenantNoun = tenantView ? "this tenant" : "your tenant";
const budgetNoun = tenantView ? "this tenant's organization budget" : "your organization budget";
const planNoun = tenantView ? "its plan" : "your plan";
// Aggregate records by date
const dailyAggregates: DailyAggregate[] = (() => {
const byDate = new Map<string, { total: number; endpoints: Set<string> }>();
for (const r of records) {
const existing = byDate.get(r.date);
if (existing) {
existing.total += r.requestCount;
existing.endpoints.add(r.endpoint);
} else {
byDate.set(r.date, { total: r.requestCount, endpoints: new Set([r.endpoint]) });
}
}
return Array.from(byDate.entries())
.map(([date, v]) => ({ date, totalRequests: v.total, endpoints: v.endpoints.size }))
.sort((a, b) => b.date.localeCompare(a.date));
})();
// Tiles + chart: the tenant's charged requests per day in the tenant view, else the caller's own.
const dailyAggregates: DailyAggregate[] = tenantView
? (orgUsage?.daily ?? []).map((d) => ({ date: d.date, totalRequests: d.requests })).reverse()
: aggregateOwnRecords(records);
const totalRequests = dailyAggregates.reduce((sum, d) => sum + d.totalRequests, 0);
const todayStr = getDateTo();
@@ -208,16 +212,18 @@ export function SectionMcpUsage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900">MCP Usage</h2>
{upn && <p className="text-sm text-gray-500">{upn}</p>}
{tenantView
? <p className="text-sm text-gray-500">Tenant: {tenantLabel}</p>
: upn && <p className="text-sm text-gray-500">{upn}</p>}
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Plan Badge */}
{usagePlan && (
{/* Plan Badge — the caller's own plan, absent in another tenant's view */}
{!tenantView && usagePlan && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
Plan: {usagePlan}
</span>
)}
{!usagePlan && (
{!tenantView && !usagePlan && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
Plan: {effectivePlan ? `${effectivePlan} (inherited)` : "inherited"}
</span>
@@ -385,7 +391,7 @@ export function SectionMcpUsage() {
)}
{/* Empty State */}
{!loading && records.length === 0 && !error && (
{!loading && dailyAggregates.length === 0 && !error && (
<div className="bg-white rounded-lg shadow p-12 text-center">
<p className="text-gray-500">No usage data found for the selected period.</p>
</div>
@@ -9587,6 +9587,17 @@
"name": "McpOrganizationQuotaNode"
},
"doc": "The tenant's organization windows (plan, limits, today / this month) from the same counters."
},
{
"name": "daily",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "McpOrganizationDailyItem"
}
},
"doc": "Requests charged to the tenant per day inside the range, oldest first; days without requests are absent."
}
]
},
@@ -13334,6 +13345,27 @@
}
]
},
"McpOrganizationDailyItem": {
"kind": "object",
"doc": "One day of the tenant's organization budget, nested in GetMcpOrganizationUsageResponse.",
"fields": [
{
"name": "date",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "yyyyMMdd."
},
{
"name": "requests",
"type": {
"kind": "primitive",
"name": "number"
}
}
]
},
"McpOrganizationQuotaNode": {
"kind": "object",
"doc": "The organization-wide windows of the tenant in GetMcpOrganizationUsageResponse: its tenant plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every account charged to the tenant.",
@@ -2023,6 +2023,8 @@ export interface GetMcpOrganizationUsageResponse {
users: McpOrganizationUsageItem[];
/** The tenant's organization windows (plan, limits, today / this month) from the same counters. */
quota: McpOrganizationQuotaNode;
/** Requests charged to the tenant per day inside the range, oldest first; days without requests are absent. */
daily: McpOrganizationDailyItem[];
}
/** Per-user MCP/API usage envelope (GetMcpUserUsage). Non-global callers only receive the records attributed to their own tenant; a foreign oid and an unknown oid are indistinguishable (both 200 with empty records). The MCP server paginates over the records key — its name is wire-critical. */
@@ -2824,6 +2826,13 @@ export interface McpHealthCheckResponse {
check: HealthCheck;
}
/** One day of the tenant's organization budget, nested in GetMcpOrganizationUsageResponse. */
export interface McpOrganizationDailyItem {
/** yyyyMMdd. */
date: string;
requests: number;
}
/** The organization-wide windows of the tenant in GetMcpOrganizationUsageResponse: its tenant plan (tenant-wide override, else edition) with the limits (0 = unlimited) and the counters over every account charged to the tenant. */
export interface McpOrganizationQuotaNode {
tenantPlan: string;