Compare commits

...

15 Commits

Author SHA1 Message Date
Oliver Kieselbach f43462567e bump pointer: internal (D-419: #209 and the CI race fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:21:12 +01:00
Oliver Kieselbach b5ac4651a0 Session deletion maintenance test waits for both watchdog events
The severe-watchdog test blocked only until the severe OpsEvent and then asserted the warning as
well. The two delays are independent timers: under load the severe one can complete first, and the
cancel in the finally then drops a warning delay whose callback has not run yet (seen once in CI on
3bc8b87a). The fanout body now waits for both events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:12:08 +01:00
Oliver Kieselbach 3bc8b87a19 Bump xunit.runner.visualstudio from 2.8.2 to 4.0.1
Applied on main for #209: after the dotnet group update changed the neighbouring xunit lines, the
PR conflicted and Dependabot did not act on the rebase request. The change is the PR's own five
lines. All five test suites keep their counts (8532 / 3891 / 648 / 44 / 125).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:01:16 +01:00
Oliver Kieselbach 950b3cc679 bump pointer: internal (D-419 Dependabot wave: decision, plan, backlog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:40:25 +01:00
dependabot[bot] 0559919a9e Bump Azure.Data.Tables and 15 others (#210)
Bumps Azure.Data.Tables from 12.11.0 to 12.13.0
Bumps Azure.Monitor.Query from 1.5.0 to 1.7.1
Bumps Azure.Storage.Blobs from 12.27.0 to 12.30.1
Bumps Azure.Storage.Queues from 12.25.0 to 12.28.1
Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.10 to 10.0.14
Bumps Microsoft.Azure.Functions.Worker from 2.51.0 to 2.52.0
Bumps Microsoft.Azure.Functions.Worker.ApplicationInsights from 2.50.0 to 2.51.0
Bumps Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore from 2.1.0 to 2.1.1
Bumps Microsoft.Azure.Functions.Worker.Extensions.SignalRService from 2.0.1 to 2.1.0
Bumps Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues from 5.5.4 to 5.5.5
Bumps Microsoft.Azure.Functions.Worker.Sdk from 2.0.7 to 2.1.0
Bumps Microsoft.Azure.SignalR.Management from 1.33.0 to 1.33.1
Bumps Microsoft.Identity.Web from 4.3.0 to 4.16.1
Bumps Moq from 4.20.72 to 4.21.0
Bumps Newtonsoft.Json from 13.0.3 to 13.0.4
Bumps xunit from 2.9.2 to 2.9.3

---
updated-dependencies:
- dependency-name: Newtonsoft.Json
  dependency-version: 13.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Newtonsoft.Json
  dependency-version: 13.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Newtonsoft.Json
  dependency-version: 13.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Azure.Data.Tables
  dependency-version: 12.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Azure.Monitor.Query
  dependency-version: 1.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Azure.Storage.Blobs
  dependency-version: 12.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Azure.Storage.Queues
  dependency-version: 12.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker
  dependency-version: 2.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.ApplicationInsights
  dependency-version: 2.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Extensions.SignalRService
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues
  dependency-version: 5.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Sdk
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.SignalR.Management
  dependency-version: 1.33.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Identity.Web
  dependency-version: 4.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Moq
  dependency-version: 4.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 23:26:42 +01:00
dependabot[bot] e8f8b876e3 chore(deps-dev): bump the web group across 1 directory with 2 updates (#202)
Bumps the web group with 2 updates in the /src/Web/autopilot-monitor-web directory: [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) and [postcss](https://github.com/postcss/postcss).


Updates `knip` from 6.43.0 to 6.44.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.44.0/packages/knip)

Updates `postcss` from 8.5.25 to 8.5.32
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.25...8.5.32)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: postcss
  dependency-version: 8.5.32
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 23:15:54 +01:00
dependabot[bot] 83afd4f1c9 chore(deps-dev): bump @azure/static-web-apps-cli (#200)
Bumps the swa-cli group with 1 update in the /tools/swa-cli directory: [@azure/static-web-apps-cli](https://github.com/azure/static-web-apps-cli).


Updates `@azure/static-web-apps-cli` from 2.0.10 to 2.1.0
- [Release notes](https://github.com/azure/static-web-apps-cli/releases)
- [Commits](https://github.com/azure/static-web-apps-cli/compare/v2.0.10...v2.1.0)

---
updated-dependencies:
- dependency-name: "@azure/static-web-apps-cli"
  dependency-version: 2.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: swa-cli
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 23:11:36 +01:00
dependabot[bot] d695c9b8c4 chore(deps-dev): bump knip (#201)
Bumps the mcp group with 1 update in the /src/McpServer/autopilot-monitor-mcp directory: [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).


Updates `knip` from 6.43.0 to 6.44.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.44.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:58:41 +01:00
dependabot[bot] 1ee7c48c92 Bump System.Text.Json from 9.0.1 to 10.0.14 (#207)
---
updated-dependencies:
- dependency-name: System.Text.Json
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:47:41 +01:00
dependabot[bot] 98ac414208 Bump Microsoft.NET.Test.Sdk from 17.11.1 to 18.11.0 (#206)
---
updated-dependencies:
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:47:35 +01:00
dependabot[bot] 5153e90e7c Bump Microsoft.Extensions.Http.Polly from 8.0.25 to 10.0.14 (#205)
---
updated-dependencies:
- dependency-name: Microsoft.Extensions.Http.Polly
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:47:29 +01:00
Oliver Kieselbach 5b139f90e7 Backend: Pkcs past two .NET DoS advisories, Moq 4.21-safe log assertion, no Dependabot major for Application Insights 3
- Microsoft.Identity.Web 4.3.0 brings System.Security.Cryptography.Pkcs 10.0.0 through its token
  cache, affected by GHSA-846f-f764-76mx and GHSA-2fmv-rv2c-59x7 (fixed in 10.0.13). A direct
  reference lifts it to 10.0.14 until Microsoft.Identity.Web 4.17.0 or later is in.
- Moq 4.21 annotates the It.IsAnyType match parameter as nullable. The GraphTokenService log
  assertion checks for null first, so the pending dotnet group update compiles.
- Dependabot no longer proposes Microsoft.ApplicationInsights.WorkerService 3: the OpenTelemetry
  rewrite drops telemetry processors and the Functions worker integration still builds on 2.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:45:55 +01:00
Oliver Kieselbach 05d21cb240 bump pointer: internal (D-418 released: plan review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:26:12 +01:00
Oliver Kieselbach 5cc69a696a bump pointer: internal, .claude/commands (D-418 get_api_usage)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:10:24 +01:00
Oliver Kieselbach fd3ce44c0c get_api_usage reads the MCP usage overview; totals, minute shares and tenant names come from the backend
The MCP tool get_api_usage (Global Admin and Global Reader) shows what the
admin MCP Usage page shows: view=summary (totals, top tools and tenants,
newest quota refusals, accounts closest to a per-minute limit, each list
cut with its full length stated), view=tools|tenants|quotaHits|
minuteLimits|daily (one list in full), view=organization (one tenant's
budget by account and day) and view=account (one account's requests by
tool, path and day). tenantId and userId must be GUIDs. The overview call
outlasts the route's telemetry budget.

The overview response gains totals, a sharePercent per minute row and the
tenant's domainName, computed once in McpUsageOverviewService; the page
reads them instead of computing its own. The tenant configuration is read
once per tenant and a failed read only drops the name and the per-minute
override.

Removed: GET global/metrics/mcp-usage and /daily with their DTOs and the
repository reads behind them; nothing reads the per-user usage log as a
whole any more. Also removed: the unused web URL helper for the per-user
route and the MCP usage pager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:10:23 +01:00
49 changed files with 1113 additions and 711 deletions
+5
View File
@@ -24,6 +24,11 @@ updates:
dotnet-security:
applies-to: security-updates
patterns: ['*']
# Application Insights 3 is the OpenTelemetry rewrite (no telemetry processors, another sampling
# model) and the Functions worker integration still builds on 2.x: a planned migration, not a bump.
ignore:
- dependency-name: 'Microsoft.ApplicationInsights.WorkerService'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: /src/Web/autopilot-monitor-web
@@ -18,9 +18,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -44,7 +44,7 @@
<Import Project="V2Isolation.targets" />
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
@@ -78,7 +78,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
@@ -13,9 +13,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -15,9 +15,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -15,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
@@ -13,10 +13,10 @@
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
which would otherwise surface NU1903/NU1904 advisories here. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="Moq" Version="4.21.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -451,7 +451,7 @@ public class GraphTokenServiceTests
logger.Verify(l => l.Log(
Microsoft.Extensions.Logging.LogLevel.Warning,
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state != null && state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
Moq.It.IsAny<Exception?>(),
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
}
@@ -1,6 +1,9 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using static AutopilotMonitor.Functions.Services.McpUsageOverviewService;
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
@@ -102,9 +105,11 @@ public class McpUsageOverviewAssemblyTests
[TenantA] = new("community", 100, 250),
[TenantB] = new("pro", 1500, 15000),
};
// TenantB has no readable configuration: no name.
var names = new Dictionary<string, string> { [TenantA] = "contoso.com" };
var minute = new Dictionary<(string Tenant, string Principal), int> { [(TenantA, Alice)] = 120, [(TenantB, Bob)] = 150 };
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, LatestRoles(result.Calls), minute, result));
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, names, LatestRoles(result.Calls), minute, result));
}
[Fact]
@@ -225,6 +230,62 @@ public class McpUsageOverviewAssemblyTests
Assert.Equal(new[] { Bob, Alice }, minute.Select(m => m.Principal)); // 120/150 before 40/120 and 12/60
Assert.Equal((120L, 150, 2L), (minute[0].PeakRequests, minute[0].BackendLimit, minute[0].BackendRefused));
Assert.Equal((TenantA, 12L, 7L, 0L), (minute[1].TenantId, minute[1].PeakCalls, minute[1].McpRefused, minute[1].BackendRefused));
// The share the list ranks by, in whole percent: the higher of calls/MCP limit and requests/backend limit.
Assert.Equal(new[] { 80, 33 }, minute.Select(m => m.SharePercent)); // 120/150; max(12/60, 40/120)
}
[Fact]
public void Totals_SumTheLists_OnceForEveryConsumer()
{
var totals = Overview().Totals;
Assert.Equal(18, totals.Calls); // every tool of the ranking, search_docs' 4 MCP-only calls included
Assert.Equal(4, totals.McpOnlyCalls);
Assert.Equal(7, totals.Tools);
Assert.Equal(62, totals.Requests); // tenant A 55, tenant B 7 — the operator's 100 left out
Assert.Equal(2, totals.Tenants);
Assert.Equal(4, totals.Accounts); // alice, carol, the app, bob
Assert.Equal(1, totals.ServicePrincipals);
Assert.Equal(1, totals.QuotaHitAccounts); // alice's daily window
Assert.Equal(2, totals.MinuteRefusedAccounts); // alice at the MCP server, bob at the backend
Assert.Equal(2, totals.LimitAccounts);
}
[Fact]
public void Totals_WithoutTheirStore_AreAbsent_NotAPartialSum()
{
var totals = Overview(telemetry: false).Totals;
Assert.Null(totals.Calls);
Assert.Null(totals.McpOnlyCalls);
Assert.Equal(0, totals.Tools);
Assert.Equal(62, totals.Requests); // the counters still answer
Assert.Equal(3, totals.Accounts);
}
[Fact]
public void Tenants_CarryTheirDomain_WhenTheConfigurationNamesOne()
{
var tenants = Overview().Tenants;
Assert.Equal("contoso.com", tenants.Single(t => t.TenantId == TenantA).DomainName);
Assert.Null(tenants.Single(t => t.TenantId == TenantB).DomainName);
}
[Fact]
public async Task AnUnreadableTenantConfiguration_LeavesOnlyTheNameOut()
{
var config = new Mock<TenantConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
{ CallBase = false };
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantA)).ThrowsAsync(new InvalidOperationException("storage down"));
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantB)).ReturnsAsync(new TenantConfiguration { TenantId = TenantB, DomainName = "fabrikam.com" });
// Only the configuration read is exercised; the other collaborators stay unused.
var service = new McpUsageOverviewService(
null!, null!, null!, null!, config.Object, null!, null!, null!, NullLogger<McpUsageOverviewService>.Instance, TimeProvider.System);
Assert.Null(await service.ReadTenantConfigurationAsync(TenantA));
Assert.Equal("fabrikam.com", (await service.ReadTenantConfigurationAsync(TenantB))?.DomainName);
}
[Theory]
@@ -108,16 +108,10 @@ public class MetricsFilterInjectionTests
}
[Fact]
public void BuildTenantUsageFilter_TenantInjection_StaysInsideLiteral()
public void BuildUserUsageFilter_BothDates_NormalizeTheShapeAndBoundTheRange()
{
var filter = TableUserUsageRepository.BuildTenantUsageFilter("' or TenantId ne '", null, null);
var filter = TableUserUsageRepository.BuildUserUsageFilter("user1", "2026-11-01", "2026-11-05");
Assert.Equal("TenantId eq ''' or TenantId ne '''", filter);
}
[Fact]
public void BuildTenantUsageFilter_NoScope_ReturnsNull()
{
Assert.Null(TableUserUsageRepository.BuildTenantUsageFilter(null, null, null));
Assert.Equal("PartitionKey eq 'user1' and Date ge '20261101' and Date le '20261105'", filter);
}
}
@@ -548,64 +548,6 @@ public class MetricsWireParityTests
new GetMcpUserUsageResponse { UserId = userId, Records = records });
}
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
}
[Fact]
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var records = new List<UserUsageRecord>();
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
}
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var summaries = new List<UserUsageDailySummary>
{
new UserUsageDailySummary
{
Date = "20260830",
TenantId = tenantId,
TotalRequests = 321,
UniqueUsers = 4,
UniqueEndpoints = 11,
},
};
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
}
[Fact]
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var summaries = new List<UserUsageDailySummary>();
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
}
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
[Fact]
@@ -850,12 +792,18 @@ public class MetricsWireParityTests
dateTo = "20261105",
includesPlatformAdmins = false,
telemetry = new { requestsAvailable = true, mcpLogAvailable = true, mcpLogFrom = "20261104", partial = true },
totals = new
{
calls = 10L, mcpOnlyCalls = 4L, tools = 2, requests = 50L, tenants = 1, accounts = 1, servicePrincipals = 0,
quotaHitAccounts = 1, minuteRefusedAccounts = 1, limitAccounts = 1,
},
daily = new[] { new { date = "20261105", calls = 10L, requests = 35L, accounts = 3, callsByPlan = new[] { new { plan = "community", calls = 10L } } } },
tenants = new[]
{
new
{
tenantId,
domainName = "contoso.com",
quota,
calls = 7L,
requests = 50L,
@@ -912,7 +860,7 @@ public class MetricsWireParityTests
{
new
{
tenantId, principal = "admin@contoso.com",
tenantId, principal = "admin@contoso.com", sharePercent = 33,
peakCalls = 12L, peakCallsAt = at, mcpRefused = 7L, mcpLastRefusedAt = at,
peakRequests = 40L, peakRequestsAt = at, backendLimit = 120, backendRefused = 2L, backendLastRefusedAt = at,
},
@@ -925,6 +873,11 @@ public class MetricsWireParityTests
DateTo = "20261105",
IncludesPlatformAdmins = false,
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = true, McpLogAvailable = true, McpLogFrom = "20261104", Partial = true },
Totals = new McpUsageTotalsNode
{
Calls = 10, McpOnlyCalls = 4, Tools = 2, Requests = 50, Tenants = 1, Accounts = 1, ServicePrincipals = 0,
QuotaHitAccounts = 1, MinuteRefusedAccounts = 1, LimitAccounts = 1,
},
Daily = new List<McpUsageDayItem>
{
new() { Date = "20261105", Calls = 10, Requests = 35, Accounts = 3, CallsByPlan = new List<McpUsagePlanCallsItem> { new() { Plan = "community", Calls = 10 } } },
@@ -934,6 +887,7 @@ public class MetricsWireParityTests
new()
{
TenantId = tenantId,
DomainName = "contoso.com",
Quota = quota,
Calls = 7,
Requests = 50,
@@ -990,7 +944,7 @@ public class MetricsWireParityTests
{
new()
{
TenantId = tenantId, Principal = "admin@contoso.com",
TenantId = tenantId, Principal = "admin@contoso.com", SharePercent = 33,
PeakCalls = 12, PeakCallsAt = at, McpRefused = 7, McpLastRefusedAt = at,
PeakRequests = 40, PeakRequestsAt = at, BackendLimit = 120, BackendRefused = 2, BackendLastRefusedAt = at,
},
@@ -1014,6 +968,7 @@ public class MetricsWireParityTests
dateTo = "20261105",
includesPlatformAdmins = true,
telemetry = new { requestsAvailable = false, mcpLogAvailable = false },
totals = new { tools = 1, requests = 0L, tenants = 1, accounts = 1, servicePrincipals = 1, quotaHitAccounts = 0, minuteRefusedAccounts = 0, limitAccounts = 0 },
daily = Array.Empty<object>(),
tenants = new[]
{
@@ -1040,6 +995,7 @@ public class MetricsWireParityTests
DateTo = "20261105",
IncludesPlatformAdmins = true,
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = false, McpLogAvailable = false },
Totals = new McpUsageTotalsNode { Tools = 1, Requests = 0, Tenants = 1, Accounts = 1, ServicePrincipals = 1 },
Daily = new List<McpUsageDayItem>(),
Tenants = new List<McpUsageTenantItem>
{
@@ -1201,16 +1201,17 @@ public class PolicyEnforcementMiddlewareTests
/// <summary>
/// Operator reads whose ?tenantId= is a handler-side filter for a Global Admin / Global Reader: session reports
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), a
/// tenant's MCP usage (its accounts and their tools) and verdict calibration (classifier diagnostics, drift
/// alerts). None of it is the managed tenant's telemetry, so a delegated (MSP) reader never reaches them.
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), MCP
/// usage (a tenant's organization budget by account, the all-tenant overview with every tenant's accounts and
/// tools) and verdict calibration (classifier diagnostics, drift alerts). None of it is the managed tenant's
/// telemetry, so a delegated (MSP) reader never reaches them.
/// </summary>
public static TheoryData<string> OperatorFilterReads => new()
{
"/api/global/session-reports",
"/api/global/rule-submissions",
"/api/global/metrics/mcp-usage",
"/api/global/metrics/mcp-usage/daily",
"/api/global/metrics/mcp-usage/organization",
"/api/global/metrics/mcp-usage/overview",
"/api/global/metrics/verdict-calibration",
};
@@ -126,20 +126,21 @@ public class SessionDeletionMaintenanceFunctionTests
[Fact]
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
{
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
// sleep outlasting a fixed wall-clock threshold.
// Deterministic: the body blocks until BOTH watchdogs have recorded their OpsEvents. The two
// delays are independent timers, so under load the severe one can complete first; the cancel
// in the finally would then drop a warn delay whose callback had not run yet.
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
harness.SetKillSwitch(false);
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
{
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
});
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
// Both watchdogs fired while the body was still running.
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
}
@@ -39,23 +39,26 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.5.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.0.1" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.4" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageReference Include="Azure.Storage.Queues" Version="12.25.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.5" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.1.0" />
<PackageReference Include="Azure.Data.Tables" Version="12.13.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
<PackageReference Include="Azure.Storage.Queues" Version="12.28.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.16.1" />
<!-- Transitive via Microsoft.Identity.Web's token cache, lifted past GHSA-846f-f764-76mx and
GHSA-2fmv-rv2c-59x7. Remove once Microsoft.Identity.Web is 4.17.0 or later (it brings 10.0.13). -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
</ItemGroup>
<ItemGroup>
@@ -88,44 +88,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return records;
}
public async Task<List<UserUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
{
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
var records = new List<UserUsageRecord>();
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
{
records.Add(MapToRecord(entity));
}
return records;
}
public async Task<List<UserUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null)
{
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
var records = new List<UserUsageRecord>();
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
{
records.Add(MapToRecord(entity));
}
var grouped = records
.GroupBy(r => r.Date)
.Select(g => new UserUsageDailySummary
{
Date = g.Key,
TenantId = tenantId,
TotalRequests = g.Sum(r => r.RequestCount),
UniqueUsers = g.Select(r => r.UserId).Distinct().Count(),
UniqueEndpoints = g.Select(r => r.Endpoint).Distinct().Count(),
})
.OrderByDescending(s => s.Date)
.ToList();
return grouped;
}
/// <summary>
/// Builds the OData filter for a per-user usage query. <paramref name="userId"/> is a route
/// segment (AAD oid) and the dates are query params, so all values MUST be OData-escaped before
@@ -135,42 +97,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
internal static string BuildUserUsageFilter(string userId, string? dateFrom, string? dateTo)
{
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(userId)}'";
return AppendDateFilter(filter, dateFrom, dateTo);
}
/// <summary>
/// Builds the OData filter for a per-tenant usage query. Same escaping requirement as
/// <see cref="BuildUserUsageFilter"/>; returns null when no tenant/date scope is supplied.
/// </summary>
internal static string? BuildTenantUsageFilter(string? tenantId, string? dateFrom, string? dateTo)
{
string? filter = !string.IsNullOrEmpty(tenantId)
? $"TenantId eq '{ODataSanitizer.EscapeValue(tenantId)}'"
: null;
return AppendDateFilter(filter, dateFrom, dateTo);
}
private static string AppendDateFilter(string? existingFilter, string? dateFrom, string? dateTo)
{
var filter = existingFilter ?? "";
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
// so the value must still be OData-escaped before interpolation.
if (!string.IsNullOrEmpty(dateFrom))
{
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
// so the value must still be OData-escaped before interpolation.
var dateVal = ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""));
var clause = $"Date ge '{dateVal}'";
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
}
filter += $" and Date ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
if (!string.IsNullOrEmpty(dateTo))
{
var dateVal = ODataSanitizer.EscapeValue(dateTo.Replace("-", ""));
var clause = $"Date le '{dateVal}'";
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
}
filter += $" and Date le '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}'";
return string.IsNullOrEmpty(filter) ? null! : filter;
return filter;
}
public async Task<int> DeleteRecordsOlderThanAsync(string dateCutoff)
@@ -341,59 +341,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
/// <summary>
/// GET /api/global/metrics/mcp-usage?tenantId=&amp;dateFrom=&amp;dateTo= — Global usage
/// </summary>
[Function("GetGlobalMcpUsage")]
public async Task<HttpResponseData> GetGlobalUsage(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage")] HttpRequestData req)
{
_logger.LogInformation("Global MCP usage requested");
try
{
var tenantId = req.Query["tenantId"];
var dateFrom = req.Query["dateFrom"];
var dateTo = req.Query["dateTo"];
var records = await _userUsageRepo.GetUsageByTenantAsync(tenantId ?? "", dateFrom, dateTo);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
/// <summary>
/// GET /api/global/metrics/mcp-usage/daily?tenantId=&amp;dateFrom=&amp;dateTo= — Daily summaries
/// </summary>
[Function("GetGlobalMcpUsageDaily")]
public async Task<HttpResponseData> GetGlobalUsageDaily(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/daily")] HttpRequestData req)
{
_logger.LogInformation("Global MCP daily usage requested");
try
{
var tenantId = req.Query["tenantId"];
var dateFrom = req.Query["dateFrom"];
var dateTo = req.Query["dateTo"];
var summaries = await _userUsageRepo.GetDailySummaryAsync(tenantId, dateFrom, dateTo);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
}
}
@@ -575,11 +575,9 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/metrics/sla", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// A named tenant's MCP use — its accounts (UPNs) and their tools, the daily counts, the organization view.
// The handlers read ?tenantId= themselves: deliberately None, not QueryParam, so the delegated path never
// reaches them.
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin),
// MCP use across tenants: a named tenant's organization budget by account (UPNs) and the all-tenant overview
// with every tenant's accounts and tools. The organization handler reads ?tenantId= itself: deliberately None,
// not QueryParam, so the delegated path never reaches either.
new("GET", "global/metrics/mcp-usage/organization", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage/overview", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
@@ -1,4 +1,5 @@
using System.Globalization;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -88,19 +89,47 @@ public class McpUsageOverviewService
facts[(account.Tenant, principal)] = new AccountFacts(account.UserId, platformRole, state);
});
// Every tenant the overview names — the per-minute rows included — with its plan and its configuration, read once.
var tenants = counters.Select(r => r.TenantId.ToLowerInvariant())
.Concat(telemetry.Calls.Select(r => r.Tenant))
.Concat(telemetry.Peaks.Select(p => p.Tenant))
.Concat(telemetry.Refusals.Select(r => r.Tenant))
.Where(t => !string.IsNullOrEmpty(t))
.Distinct(StringComparer.Ordinal)
.ToList();
var tenantLimits = new System.Collections.Concurrent.ConcurrentDictionary<string, McpQuotaService.TenantPlanLimits>(StringComparer.Ordinal);
var tenantConfigs = new System.Collections.Concurrent.ConcurrentDictionary<string, TenantConfiguration?>(StringComparer.Ordinal);
await Parallel.ForEachAsync(tenants, new ParallelOptions { MaxDegreeOfParallelism = AccountReadParallelism }, async (tenant, _) =>
tenantLimits[tenant] = await _quota.ResolveTenantPlanAsync(tenant));
{
tenantLimits[tenant] = await _quota.ResolveTenantPlanAsync(tenant);
tenantConfigs[tenant] = await ReadTenantConfigurationAsync(tenant);
});
var minuteLimits = await ResolveMinuteLimitsAsync(telemetry, facts, telemetryRoles);
var minuteLimits = await ResolveMinuteLimitsAsync(telemetry, facts, telemetryRoles, tenantConfigs);
var tenantNames = tenantConfigs
.Where(c => !string.IsNullOrWhiteSpace(c.Value?.DomainName))
.ToDictionary(c => c.Key, c => c.Value!.DomainName, StringComparer.Ordinal);
return Assemble(new OverviewInputs(
dates, includePlatformAdmins, counters, facts, tenantLimits, telemetryRoles, minuteLimits, telemetry));
dates, includePlatformAdmins, counters, facts, tenantLimits, tenantNames, telemetryRoles, minuteLimits, telemetry));
}
/// <summary>
/// The tenant's configuration (cached) for its name and its per-minute override. A storage failure leaves both
/// out and is logged; it never fails the overview, which is about usage, not configuration.
/// </summary>
internal async Task<TenantConfiguration?> ReadTenantConfigurationAsync(string tenantId)
{
try
{
return await _tenantConfig.GetConfigurationIfExistsAsync(tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[McpUsageOverview] Configuration of tenant {TenantId} unreadable: name and per-minute override left out",
LogSanitizer.Clean(tenantId));
return null;
}
}
// ── Telemetry ──────────────────────────────────────────────────────────────────
@@ -170,7 +199,8 @@ public class McpUsageOverviewService
/// </summary>
private async Task<Dictionary<(string Tenant, string Principal), int>> ResolveMinuteLimitsAsync(
TelemetryResult telemetry, IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> facts,
IReadOnlyDictionary<(string Tenant, string Principal), string> telemetryRoles)
IReadOnlyDictionary<(string Tenant, string Principal), string> telemetryRoles,
IReadOnlyDictionary<string, TenantConfiguration?> tenantConfigs)
{
var config = await _adminConfig.GetConfigurationAsync();
var keys = telemetry.Peaks.Select(p => (p.Tenant, p.Principal))
@@ -193,9 +223,8 @@ public class McpUsageOverviewService
{
if (!perTenant.TryGetValue(key.Tenant, out tenantSide))
{
var tenantConfig = await _tenantConfig.GetConfigurationIfExistsAsync(key.Tenant);
var entitlements = await _entitlements.GetEntitlementsAsync(key.Tenant);
tenantSide = (tenantConfig?.CustomUserRateLimitRequestsPerMinute, entitlements.UserRateLimitPerMinute);
tenantSide = (tenantConfigs.GetValueOrDefault(key.Tenant)?.CustomUserRateLimitRequestsPerMinute, entitlements.UserRateLimitPerMinute);
perTenant[key.Tenant] = tenantSide;
}
}
@@ -262,6 +291,7 @@ public class McpUsageOverviewService
IReadOnlyList<TenantUsageRecord> Counters,
IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> Accounts,
IReadOnlyDictionary<string, McpQuotaService.TenantPlanLimits> TenantLimits,
IReadOnlyDictionary<string, string> TenantNames,
IReadOnlyDictionary<(string Tenant, string Principal), string> TelemetryRoles,
IReadOnlyDictionary<(string Tenant, string Principal), int> MinuteLimits,
TelemetryResult Telemetry);
@@ -386,6 +416,7 @@ public class McpUsageOverviewService
return new McpUsageTenantItem
{
TenantId = tenant,
DomainName = input.TenantNames.GetValueOrDefault(tenant),
Quota = McpQuotaNodes.BuildOrganizationQuota(tenantRows, limits, dates.Today, dates.MonthStart),
Calls = members.Sum(a => a.Calls),
Requests = members.Sum(a => a.Requests),
@@ -466,6 +497,10 @@ public class McpUsageOverviewService
.ToDictionary(g => g.Key, g => g.ToList());
var mcpLimit = telemetry.ServerFacts.RateLimitPerMinute;
// The higher of the two shares of a minute limit (0–1+); the list ranks by it and shows it in whole percent.
double MinuteShare(long peakCalls, long peakRequests, int backendLimit) => Math.Max(
mcpLimit is int limit && limit > 0 ? (double)peakCalls / limit : 0,
backendLimit > 0 ? (double)peakRequests / backendLimit : 0);
var minuteItems = peaks.Keys.Concat(mcpRefusals.Keys).Concat(backendRefusals.Keys).Distinct()
.Select(key =>
{
@@ -474,25 +509,27 @@ public class McpUsageOverviewService
var requestPeak = accountPeaks?.FirstOrDefault(p => p.Metric == PeakRequests);
mcpRefusals.TryGetValue(key, out var mcpRefused);
var hasBackendRefusal = backendRefusals.TryGetValue(key, out var backendRefused);
return new McpUsageMinuteItem
var backendLimit = input.MinuteLimits.GetValueOrDefault(key);
var share = MinuteShare(callPeak?.Peak ?? 0, requestPeak?.Peak ?? 0, backendLimit);
return (Share: share, Item: new McpUsageMinuteItem
{
TenantId = key.Tenant,
Principal = key.Principal,
SharePercent = (int)Math.Round(share * 100, MidpointRounding.AwayFromZero),
PeakCalls = callPeak?.Peak ?? 0,
PeakCallsAt = callPeak?.PeakAt,
McpRefused = mcpRefused?.Refused ?? 0,
McpLastRefusedAt = mcpRefused?.LastAt,
PeakRequests = requestPeak?.Peak ?? 0,
PeakRequestsAt = requestPeak?.PeakAt,
BackendLimit = input.MinuteLimits.GetValueOrDefault(key),
BackendLimit = backendLimit,
BackendRefused = hasBackendRefusal ? backendRefused.Refused : 0,
BackendLastRefusedAt = hasBackendRefusal ? backendRefused.LastAt : null,
};
});
})
.OrderByDescending(m => Math.Max(
mcpLimit is int limit && limit > 0 ? (double)m.PeakCalls / limit : 0,
m.BackendLimit > 0 ? (double)m.PeakRequests / m.BackendLimit : 0))
.ThenBy(m => m.Principal, StringComparer.Ordinal)
.OrderByDescending(m => m.Share)
.ThenBy(m => m.Item.Principal, StringComparer.Ordinal)
.Select(m => m.Item)
.ToList();
var mcpLogFrom = telemetry.ServerFacts.Oldest is DateTime oldest
@@ -511,6 +548,7 @@ public class McpUsageOverviewService
McpLogFrom = mcpLogFrom != null && string.CompareOrdinal(mcpLogFrom, dates.From) > 0 ? mcpLogFrom : null,
Partial = telemetry.Partial ? true : null,
},
Totals = BuildTotals(tenants, tools, quotaHits, minuteItems, telemetry),
Daily = daily,
Tenants = tenants,
Tools = tools,
@@ -519,5 +557,36 @@ public class McpUsageOverviewService
};
}
/// <summary>
/// Pure: the totals over the assembled lists. Calls need the request telemetry and MCP-only calls the MCP log —
/// without the store the figure is absent, never a misleading partial sum. An account is a tenant and principal;
/// an organization-level refusal counts for the account that met it.
/// </summary>
private static McpUsageTotalsNode BuildTotals(
IReadOnlyList<McpUsageTenantItem> tenants, IReadOnlyList<McpUsageToolItem> tools,
IReadOnlyList<McpUsageQuotaHitItem> quotaHits, IReadOnlyList<McpUsageMinuteItem> minuteItems, TelemetryResult telemetry)
{
var accounts = tenants.SelectMany(t => t.Accounts).ToList();
var quotaHitAccounts = quotaHits.Select(h => (h.TenantId, Principal: h.Principal ?? string.Empty)).ToHashSet();
var minuteRefusedAccounts = minuteItems
.Where(m => m.McpRefused > 0 || m.BackendRefused > 0)
.Select(m => (m.TenantId, m.Principal))
.ToHashSet();
return new McpUsageTotalsNode
{
Calls = telemetry.RequestsAvailable ? tools.Sum(t => t.Calls) : null,
McpOnlyCalls = telemetry.McpLogAvailable ? tools.Where(t => t.Accounts == null).Sum(t => t.Calls) : null,
Tools = tools.Count,
Requests = tenants.Sum(t => t.Requests),
Tenants = tenants.Count,
Accounts = accounts.Count,
ServicePrincipals = accounts.Count(a => Constants.PrincipalKeys.IsApplication(a.Principal)),
QuotaHitAccounts = quotaHitAccounts.Count,
MinuteRefusedAccounts = minuteRefusedAccounts.Count,
LimitAccounts = quotaHitAccounts.Union(minuteRefusedAccounts).Count(),
};
}
private static DateTime? Later(DateTime? a, DateTime? b) => a == null ? b : b == null ? a : (a > b ? a : b);
}
+4 -4
View File
@@ -30,7 +30,7 @@
"@types/express": "^5.0.6",
"@types/node": "^22.20.5",
"eslint": "^9.39.5",
"knip": "^6.43.0",
"knip": "^6.44.0",
"tsx": "^4.23.15",
"typescript": "^5.0.0",
"typescript-eslint": "^8.72.1",
@@ -4351,9 +4351,9 @@
}
},
"node_modules/knip": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.43.0.tgz",
"integrity": "sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==",
"version": "6.44.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
"dev": true,
"funding": [
{
@@ -40,7 +40,7 @@
"@types/express": "^5.0.6",
"@types/node": "^22.20.5",
"eslint": "^9.39.5",
"knip": "^6.43.0",
"knip": "^6.44.0",
"tsx": "^4.23.15",
"typescript": "^5.0.0",
"typescript-eslint": "^8.72.1",
@@ -0,0 +1,274 @@
/**
* get_api_usage: the shaping of the MCP usage views (api-usage-shape.ts) and the tool's routing.
* - the summary cuts every list to its head and states the full length; nothing is cut silently;
* - absent wire values become explicit nulls (calls without request telemetry, unreadable counters);
* - tenants are named on refusal and minute rows; an account's counter keys fold into tool and day sums;
* - organization and account need their id; a UPN is not an object id.
*
* apiFetch is mocked so these run with no backend / token.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() }));
vi.mock('../client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../client.js')>();
return { ...actual, apiFetch: apiFetchMock };
});
import { registerAdminTools } from '../tools/admin.js';
import { ObjectIdSchema } from '../tools/shared.js';
import { NO_TOOL, SUMMARY_HEAD, shapeAccount, shapeOrganization, shapeOverview, usageWindow } from '../api-usage-shape.js';
import type {
GetGlobalMcpUsageOverviewResponse,
GetMcpOrganizationUsageResponse,
GetMcpUserUsageResponse,
McpUsageAccountItem,
McpUsageMinuteItem,
McpUsageQuotaHitItem,
McpUsageTenantItem,
McpUsageToolItem,
} from '../generated/wire-types.generated.js';
const TENANT_A = 'aaaaaaaa-0000-4000-8000-00000000000a';
const TENANT_B = 'bbbbbbbb-0000-4000-8000-00000000000b';
const OID = '11111111-2222-4333-8444-555555555555';
const AT = '2026-11-05T10:00:00Z';
function account(principal: string, over: Partial<McpUsageAccountItem> = {}): McpUsageAccountItem {
return {
userId: OID,
principal,
isPlatformAdmin: false,
quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: 12, monthlyUsed: 40 },
calls: 5,
requests: 12,
quotaHits: 0,
topTools: [{ tool: 'get_session_summary', calls: 3 }],
...over,
};
}
function tenant(tenantId: string, domainName: string | undefined, accounts: McpUsageAccountItem[]): McpUsageTenantItem {
return {
tenantId,
...(domainName ? { domainName } : {}),
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 30, monthlyUsed: 90 },
calls: accounts.reduce((s, a) => s + a.calls, 0),
requests: accounts.reduce((s, a) => s + a.requests, 0),
quotaHits: 1,
accounts,
};
}
const tool = (name: string, calls: number, over: Partial<McpUsageToolItem> = {}): McpUsageToolItem =>
({ tool: name, calls, accounts: 2, tenants: 1, requestsPerCall: 4.5, errorRate: 0.1, p95DurationMs: 900, routes: [{ route: 'GET sessions/{id}', requests: 9 }], ...over });
const hit = (tenantId: string, principal: string): McpUsageQuotaHitItem =>
({ date: '20261105', tenantId, principal, level: 'user', scope: 'daily', plan: 'community', limit: 50, used: 50, refused: 3, firstAt: AT, lastAt: AT });
const minute = (tenantId: string, principal: string, sharePercent: number): McpUsageMinuteItem =>
({ tenantId, principal, sharePercent, peakCalls: 30, peakCallsAt: AT, mcpRefused: 0, peakRequests: 60, peakRequestsAt: AT, backendLimit: 120, backendRefused: 0 });
function overview(over: Partial<GetGlobalMcpUsageOverviewResponse> = {}): GetGlobalMcpUsageOverviewResponse {
return {
dateFrom: '20261007',
dateTo: '20261105',
includesPlatformAdmins: false,
telemetry: { requestsAvailable: true, mcpLogAvailable: true },
totals: {
calls: 40, mcpOnlyCalls: 4, tools: 2, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0,
quotaHitAccounts: 1, minuteRefusedAccounts: 0, limitAccounts: 1,
},
daily: [{ date: '20261105', calls: 40, requests: 24, accounts: 2, callsByPlan: [{ plan: 'community', calls: 40 }] }],
tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com')]), tenant(TENANT_B, undefined, [account('bob@fabrikam.com')])],
tools: [tool('get_session_summary', 36), tool('search_docs', 4, { accounts: undefined, tenants: undefined, requestsPerCall: 0, routes: [] })],
quotaHits: [hit(TENANT_A, 'alice@contoso.com')],
minuteLimits: { mcpServerLimit: 60, accounts: [minute(TENANT_A, 'alice@contoso.com', 50), minute(TENANT_B, 'bob@fabrikam.com', 25)] },
...over,
};
}
describe('usageWindow', () => {
it('is the trailing window of whole UTC days ending today, as yyyyMMdd', () => {
const now = new Date('2026-11-05T23:30:00Z');
expect(usageWindow(1, now)).toEqual({ from: '20261105', to: '20261105' });
expect(usageWindow(30, now)).toEqual({ from: '20261007', to: '20261105' });
});
});
describe('shapeOverview — summary', () => {
it('carries the backend totals and every list head with its full length', () => {
const out = shapeOverview(overview(), 'summary');
expect(out.view).toBe('summary');
expect(out.window).toEqual({ from: '20261007', to: '20261105' });
expect(out.telemetry).toEqual({ requestsAvailable: true, mcpLogAvailable: true, mcpLogFrom: null, partial: false });
expect(out.totals).toMatchObject({ calls: 40, mcpOnlyCalls: 4, requests: 24, limitAccounts: 1 });
expect(out.lists).toEqual({ tools: 2, tenants: 2, quotaHits: 1, minuteLimits: 2 });
expect(out).not.toHaveProperty('note'); // nothing was cut
const [summaryTool, docs] = out.tools as Array<Record<string, unknown>>;
expect(summaryTool).toEqual({ tool: 'get_session_summary', calls: 36, requestsPerCall: 4.5, accounts: 2, tenants: 1, errorRate: 0.1, p95DurationMs: 900 });
expect(docs).toMatchObject({ tool: 'search_docs', accounts: null, tenants: null }); // runs in the MCP server alone
const [contoso, unnamed] = out.tenants as Array<Record<string, unknown>>;
expect(contoso).toMatchObject({ domainName: 'contoso.com', plan: 'community', accounts: 1, today: { used: 30, limit: 100 }, month: { used: 90, limit: 250 } });
expect(unnamed.domainName).toBeNull();
expect((out.quotaHits as Array<Record<string, unknown>>)[0]).toMatchObject({ domainName: 'contoso.com', principal: 'alice@contoso.com', used: 50, limit: 50 });
const minuteLimits = out.minuteLimits as { mcpServerLimit: number; accounts: Array<Record<string, unknown>> };
expect(minuteLimits.mcpServerLimit).toBe(60);
expect(minuteLimits.accounts.map((m) => [m.principal, m.domainName, m.sharePercent])).toEqual([
['alice@contoso.com', 'contoso.com', 50],
['bob@fabrikam.com', null, 25],
]);
});
it('cuts a long list to its head and says so', () => {
const tools = Array.from({ length: SUMMARY_HEAD.tools + 3 }, (_, i) => tool(`tool_${i}`, 100 - i));
const out = shapeOverview(overview({ tools }), 'summary');
expect(out.tools).toHaveLength(SUMMARY_HEAD.tools);
expect((out.lists as Record<string, number>).tools).toBe(SUMMARY_HEAD.tools + 3);
expect(out.note).toMatch(/cut to their first rows/);
});
it('shows calls as null, not 0, when the request telemetry did not answer', () => {
const out = shapeOverview(overview({
telemetry: { requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true },
totals: { tools: 0, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0, quotaHitAccounts: 0, minuteRefusedAccounts: 0, limitAccounts: 0 },
}), 'summary');
expect(out.totals).toMatchObject({ calls: null, mcpOnlyCalls: null, requests: 24 });
expect(out.telemetry).toEqual({ requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true });
});
});
describe('shapeOverview — one list in full', () => {
it('tenants carry every account with explicit windows; an unreadable counter is null', () => {
const unreadable = account('carol@contoso.com', { quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: -1, monthlyUsed: -1 } });
const out = shapeOverview(overview({ tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com'), unreadable])] }), 'tenants');
const [only] = out.tenants as Array<{ accounts: Array<Record<string, unknown>> }>;
expect(only.accounts.map((a) => a.principal)).toEqual(['alice@contoso.com', 'carol@contoso.com']);
expect(only.accounts[0]).toMatchObject({ plan: 'community', today: { used: 12, limit: 50 }, month: { used: 40, limit: 250 }, role: null });
expect(only.accounts[1]).toMatchObject({ today: { used: null, limit: 50 }, month: { used: null, limit: 250 } });
});
it('tools keep their routes and quality figures; the other views return their one list', () => {
const data = overview();
const tools = shapeOverview(data, 'tools').tools as Array<Record<string, unknown>>;
expect(tools[0]).toMatchObject({ routes: [{ route: 'GET sessions/{id}', requests: 9 }], avgResultChars: null, overCapCalls: null });
expect(shapeOverview(data, 'quotaHits').quotaHits).toHaveLength(1);
expect(shapeOverview(data, 'minuteLimits')).toMatchObject({ mcpServerLimit: 60 });
expect(shapeOverview(data, 'daily').daily).toEqual(data.daily);
for (const view of ['tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const) {
expect(shapeOverview(data, view)).not.toHaveProperty('totals');
}
});
});
describe('shapeOrganization', () => {
it('names the windows and sums the accounts of the range', () => {
const data: GetMcpOrganizationUsageResponse = {
tenantId: TENANT_A,
dateFrom: '20261101',
dateTo: '20261105',
users: [
{ userId: OID, userPrincipalName: 'alice@contoso.com', requestsToday: 10, requestsThisMonth: 40, requestsInRange: 40, lastRequestAt: AT },
{ userId: 'oid-2', requestsToday: 0, requestsThisMonth: 2, requestsInRange: 2 },
],
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 42 },
daily: [{ date: '20261105', requests: 10 }],
};
const out = shapeOrganization(data);
expect(out).toMatchObject({ view: 'organization', plan: 'community', today: { used: 10, limit: 100 }, month: { used: 42, limit: 250 }, requests: 42 });
expect((out.accounts as Array<Record<string, unknown>>)[1]).toEqual(
{ principal: null, userId: 'oid-2', requestsInRange: 2, requestsToday: 0, requestsThisMonth: 2, lastRequestAt: null });
});
});
describe('shapeAccount', () => {
it('folds the counter rows into tools with their paths and into days', () => {
const row = (date: string, endpoint: string, requestCount: number, lastRequestAt = AT) =>
({ userId: OID, userPrincipalName: 'alice@contoso.com', tenantId: TENANT_A, endpoint, date, requestCount, lastRequestAt });
const data: GetMcpUserUsageResponse = {
userId: OID,
records: [
row('20261104', 'get_session_summary:sessions/{id}', 6),
row('20261105', 'get_session_summary:sessions/{id}/events', 9),
row('20261105', 'get_session_summary:sessions/{id}', 3),
row('20261105', 'auth/me', 1),
],
};
const out = shapeAccount(data, { from: '20261101', to: '20261105' });
expect(out).toMatchObject({ userId: OID, principal: 'alice@contoso.com', tenantId: TENANT_A, requests: 19 });
expect(out.byTool).toEqual([
{ tool: 'get_session_summary', requests: 18, paths: [{ path: 'sessions/{id}', requests: 9 }, { path: 'sessions/{id}/events', requests: 9 }] },
{ tool: NO_TOOL, requests: 1, paths: [{ path: 'auth/me', requests: 1 }] },
]);
expect(out.byDay).toEqual([{ date: '20261104', requests: 6 }, { date: '20261105', requests: 13 }]);
});
it('answers an account without rows with zeros, not an error', () => {
expect(shapeAccount({ userId: OID, records: [] }, { from: '20261105', to: '20261105' }))
.toEqual({ view: 'account', userId: OID, principal: null, tenantId: null, window: { from: '20261105', to: '20261105' }, requests: 0, byTool: [], byDay: [] });
});
});
// ── The tool ──────────────────────────────────────────────────────────────────
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
function handler(): Handler {
const handlers: Record<string, Handler> = {};
const fake = { registerTool: (name: string, _def: unknown, h: Handler) => { handlers[name] = h; } };
registerAdminTools(fake as never, true, true, false);
return handlers.get_api_usage;
}
describe('get_api_usage — routing', () => {
beforeEach(() => apiFetchMock.mockReset());
it('reads the overview for the overview views, with the platform-admin switch and a long timeout', async () => {
apiFetchMock.mockResolvedValueOnce(overview());
const result = await handler()({ view: 'summary', days: 7, includePlatformAdmins: true });
expect(result.isError).toBeUndefined();
const [path, init] = apiFetchMock.mock.calls[0] as [string, { signal?: AbortSignal }];
expect(path).toBe('/api/global/metrics/mcp-usage/overview?days=7&platformAdmins=include');
expect(init.signal).toBeDefined();
expect(JSON.parse(result.content[0].text).view).toBe('summary');
});
it('reads one tenant and one account over the trailing window', async () => {
apiFetchMock.mockResolvedValue({ tenantId: TENANT_A, dateFrom: '20261105', dateTo: '20261105', users: [], quota: { tenantPlan: 'pro', dailyLimit: 0, monthlyLimit: 0, dailyUsed: 0, monthlyUsed: 0 }, daily: [] });
await handler()({ view: 'organization', days: 1, tenantId: TENANT_A });
expect(apiFetchMock.mock.calls[0][0]).toMatch(new RegExp(`^/api/global/metrics/mcp-usage/organization\\?tenantId=${TENANT_A}&dateFrom=\\d{8}&dateTo=\\d{8}$`));
apiFetchMock.mockResolvedValue({ userId: OID, records: [] });
await handler()({ view: 'account', days: 1, userId: OID });
expect(apiFetchMock.mock.calls[1][0]).toMatch(new RegExp(`^/api/metrics/mcp-usage/user/${OID}\\?dateFrom=\\d{8}&dateTo=\\d{8}$`));
});
it('refuses organization without tenantId and account without userId, before any request', async () => {
const noTenant = await handler()({ view: 'organization', days: 30 });
const noUser = await handler()({ view: 'account', days: 30 });
expect(noTenant.isError).toBe(true);
expect(noTenant.content[0].text).toMatch(/tenantId is required for view=organization/);
expect(noUser.isError).toBe(true);
expect(noUser.content[0].text).toMatch(/userId .* is required for view=account/);
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('a UPN is not an object id', () => {
expect(ObjectIdSchema.safeParse(OID).success).toBe(true);
expect(ObjectIdSchema.safeParse('alice@contoso.com').success).toBe(false);
expect(ObjectIdSchema.safeParse('../global/metrics').success).toBe(false);
});
});
@@ -42,7 +42,9 @@ interface Role {
const ROLES: Role[] = [
// Raised 2026-10-28 by +1,100 each: the platform-only get_growth_metrics (measured 1,106 chars).
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 93_100 },
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_100 },
// Raised 2026-11-05 by +200: get_api_usage reads the MCP usage overview — eight views, the window, the
// platform-admin switch and two drill-down ids (measured 78,205 chars; the Global Admin catalog stayed under).
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_300 },
// Raised 2026-11-04 by +400: search_sessions gained the groupTag filter and five Autopilot
// projection keys (measured 53,318 chars; the other roles stayed under their ceilings).
{ label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin', maxCatalogChars: 53_400 },
@@ -56,8 +58,9 @@ const ROLES: Role[] = [
* Shared-mechanics arguments should read the same everywhere (shared.ts helpers); each distinct
* wording is one more contract the model has to reconcile. Ratchet on the variant count in the
* Global Admin catalog (2026-09-22 post-diet baseline; pre-diet: tenantId 28, pageSize 14, continuation 10).
* days 10 since 2026-11-05: get_api_usage's window is bounded by the 90 days the request telemetry keeps.
*/
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 9 };
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 10 };
/**
* Domains a description may name. Anything else that looks like a domain is treated as a
@@ -7,7 +7,7 @@
* sessions; the cap and a truncated flag are now surfaced.
*/
import { describe, it, expect } from 'vitest';
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateUsage, paginateInventory } from '../tools/admin.js';
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateInventory } from '../tools/admin.js';
describe('inferGeoGroupBy', () => {
it('treats a single-segment key as country', () => {
@@ -90,51 +90,6 @@ describe('platformWindowEcho', () => {
});
});
describe('paginateUsage', () => {
const records = Array.from({ length: 5 }, (_, i) => ({ endpoint: `e${i}`, count: i }));
it('pages the global per-record breakdown and emits a nextLink while more remain', () => {
const out = paginateUsage({ tenantId: 't1', records }, 2);
expect(out.tenantId).toBe('t1'); // top-level fields echoed verbatim
expect(out.totalCount).toBe(5);
expect(out.count).toBe(2);
expect(out.offset).toBe(0);
expect(out.records).toEqual(records.slice(0, 2));
expect(out.nextLink).toBe('usage-offset:2');
});
it('follows a continuation cursor to the next slice', () => {
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:2');
expect(out.offset).toBe(2);
expect(out.records).toEqual(records.slice(2, 4));
expect(out.nextLink).toBe('usage-offset:4');
});
it('drops nextLink on the final page', () => {
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:4');
expect(out.count).toBe(1);
expect(out.records).toEqual(records.slice(4));
expect(out.nextLink).toBeNull();
});
it('pages the daily "summaries" array too, preserving the key', () => {
const summaries = [{ date: '2026-06-01' }, { date: '2026-06-02' }];
const out = paginateUsage({ tenantId: 't1', summaries }, 1);
expect(out.summaries).toEqual(summaries.slice(0, 1));
expect(Object.prototype.hasOwnProperty.call(out, 'records')).toBe(false);
expect(out.nextLink).toBe('usage-offset:1');
});
it('passes an unexpected shape through untouched', () => {
const weird = { tenantId: 't1', somethingElse: 42 };
expect(paginateUsage(weird, 200)).toEqual(weird);
});
it('handles a null/non-object payload defensively', () => {
expect(paginateUsage(null, 200)).toEqual({ records: [], totalCount: 0, count: 0, offset: 0, nextLink: null });
});
});
describe('paginateInventory', () => {
const inventory = Array.from({ length: 5 }, (_, i) => ({ normalizedName: `app-${i}`, cpeUri: '' }));
const payload = { success: true, tenantId: 't1', total: 5, matched: 0, unmatched: 5, inventory };
@@ -105,19 +105,13 @@ suite('get_agent_efficiency_metrics', () => {
});
suite('get_api_usage', () => {
it('should return daily usage summary', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/daily`);
expect(Array.isArray(data.summaries)).toBe(true);
if (data.summaries.length > 0) {
expect(data.summaries[0]).toHaveProperty('date');
expect(data.summaries[0]).toHaveProperty('totalRequests');
}
});
it('should return global per-record usage', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage`);
// Response structure depends on implementation, just verify no error
expect(data).toBeDefined();
it('should return the all-tenant overview with its totals', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/overview?days=7`);
expect(data).toHaveProperty('totals');
expect(typeof data.totals.requests).toBe('number');
expect(Array.isArray(data.tenants)).toBe(true);
expect(Array.isArray(data.tools)).toBe(true);
expect(Array.isArray(data.minuteLimits.accounts)).toBe(true);
});
});
@@ -35,6 +35,8 @@ const ALLOWED_INLINE_ENUMS: Record<string, string> = {
'search_knowledge corpus selector — MCP-side, spans two backend catalogs plus the error-code catalog.',
"'inventory', 'unmatched'":
'get_software_inventory scope selector — an MCP-side view switch.',
"...OVERVIEW_VIEWS, 'organization', 'account'":
'get_api_usage view selector — an MCP-side view switch over three backend routes (api-usage-shape.ts).',
"'cmtrace', 'text'":
'test_log_pattern input format — an MCP-side parser switch.',
"'analyze', 'gather'":
@@ -0,0 +1,265 @@
/**
* LLM-facing shaping of the MCP usage views of get_api_usage:
* - the all-tenant overview (GET /api/global/metrics/mcp-usage/overview, D-416) — one list per view, or the summary;
* - one tenant's organization budget (GET /api/global/metrics/mcp-usage/organization?tenantId=);
* - one account's quota counters (GET /api/metrics/mcp-usage/user/{userId}).
*
* The backend computes every figure (totals, minute shares, rankings) once for the admin page and this tool. This
* layer only picks what a view shows, states the full length of every list the summary cuts, names tenants, folds an
* account's counter rows into sums per tool and per day, and turns absent values into explicit nulls (the wire omits
* them, which reads as "not applicable"). A quota window is { used, limit }: limit 0 = unlimited, used null = the
* counter could not be read.
*/
import type {
GetGlobalMcpUsageOverviewResponse,
GetMcpOrganizationUsageResponse,
GetMcpUserUsageResponse,
McpUsageAccountItem,
McpUsageMinuteItem,
McpUsageQuotaHitItem,
McpUsageTenantItem,
McpUsageToolItem,
} from './generated/wire-types.generated.js';
export const OVERVIEW_VIEWS = ['summary', 'tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const;
export type OverviewView = (typeof OVERVIEW_VIEWS)[number];
/** How many rows of each list the summary shows; the full list is one view away. */
export const SUMMARY_HEAD = { tools: 10, tenants: 10, quotaHits: 10, minuteLimits: 5 } as const;
/** The tool of an account's counter key that carries none (a request sent without a tool name). */
export const NO_TOOL = '(no tool)';
interface QuotaWindow {
used: number | null;
limit: number;
}
/** The wire marks an unreadable counter with -1. */
function quotaWindow(used: number, limit: number): QuotaWindow {
return { used: used < 0 ? null : used, limit };
}
const dayKey = (d: Date) => d.toISOString().slice(0, 10).replace(/-/g, '');
/** The trailing window of `days` whole UTC days ending today, as yyyyMMdd — the overview's own window. */
export function usageWindow(days: number, now: Date = new Date()): { from: string; to: string } {
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (days - 1)));
return { from: dayKey(from), to: dayKey(now) };
}
// ── Overview ──────────────────────────────────────────────────────────────────
function overviewHead(data: GetGlobalMcpUsageOverviewResponse) {
return {
window: { from: data.dateFrom, to: data.dateTo },
includesPlatformAdmins: data.includesPlatformAdmins,
telemetry: {
requestsAvailable: data.telemetry.requestsAvailable,
mcpLogAvailable: data.telemetry.mcpLogAvailable,
mcpLogFrom: data.telemetry.mcpLogFrom ?? null,
partial: data.telemetry.partial ?? false,
},
};
}
function toolRow(t: McpUsageToolItem) {
return {
tool: t.tool,
calls: t.calls,
requestsPerCall: t.requestsPerCall,
// null: the tool runs in the MCP server alone and never reaches the backend.
accounts: t.accounts ?? null,
tenants: t.tenants ?? null,
errorRate: t.errorRate ?? null,
p95DurationMs: t.p95DurationMs ?? null,
};
}
function tenantRow(t: McpUsageTenantItem) {
return {
tenantId: t.tenantId,
domainName: t.domainName ?? null,
plan: t.quota.tenantPlan,
today: quotaWindow(t.quota.dailyUsed, t.quota.dailyLimit),
month: quotaWindow(t.quota.monthlyUsed, t.quota.monthlyLimit),
calls: t.calls,
requests: t.requests,
quotaHits: t.quotaHits,
lastRequestAt: t.lastRequestAt ?? null,
};
}
function accountRow(a: McpUsageAccountItem) {
return {
principal: a.principal,
userId: a.userId ?? null,
role: a.role ?? null,
isPlatformAdmin: a.isPlatformAdmin,
plan: a.quota?.plan ?? null,
today: a.quota ? quotaWindow(a.quota.dailyUsed, a.quota.dailyLimit) : null,
month: a.quota ? quotaWindow(a.quota.monthlyUsed, a.quota.monthlyLimit) : null,
calls: a.calls,
requests: a.requests,
peakDay: a.peakDay ?? null,
peakDayRequests: a.peakDayRequests ?? null,
quotaHits: a.quotaHits,
topTools: a.topTools,
lastRequestAt: a.lastRequestAt ?? null,
};
}
function quotaHitRow(h: McpUsageQuotaHitItem, domainName: string | null) {
return {
date: h.date,
tenantId: h.tenantId,
domainName,
principal: h.principal ?? null,
level: h.level,
scope: h.scope,
plan: h.plan,
used: h.used ?? null,
limit: h.limit ?? null,
refused: h.refused,
firstAt: h.firstAt,
lastAt: h.lastAt,
};
}
function minuteRow(m: McpUsageMinuteItem, domainName: string | null) {
return {
tenantId: m.tenantId,
domainName,
principal: m.principal,
sharePercent: m.sharePercent,
peakCalls: m.peakCalls,
peakCallsAt: m.peakCallsAt ?? null,
mcpRefused: m.mcpRefused,
peakRequests: m.peakRequests,
peakRequestsAt: m.peakRequestsAt ?? null,
backendLimit: m.backendLimit,
backendRefused: m.backendRefused,
};
}
/** One view of the overview: the summary (every list cut to its head, full lengths stated) or one list in full. */
export function shapeOverview(data: GetGlobalMcpUsageOverviewResponse, view: OverviewView): Record<string, unknown> {
const names = new Map(data.tenants.filter((t) => t.domainName).map((t) => [t.tenantId.toLowerCase(), t.domainName!]));
const nameOf = (tenantId: string) => names.get(tenantId.toLowerCase()) ?? null;
const head = { view, ...overviewHead(data) };
const minute = data.minuteLimits;
switch (view) {
case 'summary': {
const lists = {
tools: data.tools.length,
tenants: data.tenants.length,
quotaHits: data.quotaHits.length,
minuteLimits: minute.accounts.length,
};
const cut = (Object.keys(lists) as Array<keyof typeof lists>).some((k) => lists[k] > SUMMARY_HEAD[k]);
return {
...head,
totals: { ...data.totals, calls: data.totals.calls ?? null, mcpOnlyCalls: data.totals.mcpOnlyCalls ?? null },
tools: data.tools.slice(0, SUMMARY_HEAD.tools).map(toolRow),
tenants: data.tenants.slice(0, SUMMARY_HEAD.tenants).map((t) => ({ ...tenantRow(t), accounts: t.accounts.length })),
quotaHits: data.quotaHits.slice(0, SUMMARY_HEAD.quotaHits).map((h) => quotaHitRow(h, nameOf(h.tenantId))),
minuteLimits: {
mcpServerLimit: minute.mcpServerLimit ?? null,
accounts: minute.accounts.slice(0, SUMMARY_HEAD.minuteLimits).map((m) => minuteRow(m, nameOf(m.tenantId))),
},
lists,
...(cut ? { note: 'Lists are cut to their first rows; lists gives each full length, the view of the same name shows it in full.' } : {}),
};
}
case 'tools':
return { ...head, tools: data.tools.map((t) => ({ ...toolRow(t), avgResultChars: t.avgResultChars ?? null, overCapCalls: t.overCapCalls ?? null, routes: t.routes })) };
case 'tenants':
return { ...head, tenants: data.tenants.map((t) => ({ ...tenantRow(t), accounts: t.accounts.map(accountRow) })) };
case 'quotaHits':
return { ...head, quotaHits: data.quotaHits.map((h) => quotaHitRow(h, nameOf(h.tenantId))) };
case 'minuteLimits':
return {
...head,
mcpServerLimit: minute.mcpServerLimit ?? null,
accounts: minute.accounts.map((m) => ({
...minuteRow(m, nameOf(m.tenantId)),
mcpLastRefusedAt: m.mcpLastRefusedAt ?? null,
backendLastRefusedAt: m.backendLastRefusedAt ?? null,
})),
};
case 'daily':
return { ...head, daily: data.daily };
}
}
// ── One tenant ────────────────────────────────────────────────────────────────
/** One tenant's organization budget: its windows, every account charged to it, and the days. */
export function shapeOrganization(data: GetMcpOrganizationUsageResponse): Record<string, unknown> {
return {
view: 'organization',
tenantId: data.tenantId,
window: { from: data.dateFrom, to: data.dateTo },
plan: data.quota.tenantPlan,
today: quotaWindow(data.quota.dailyUsed, data.quota.dailyLimit),
month: quotaWindow(data.quota.monthlyUsed, data.quota.monthlyLimit),
requests: data.users.reduce((sum, u) => sum + u.requestsInRange, 0),
accounts: data.users.map((u) => ({
principal: u.userPrincipalName ?? null,
userId: u.userId,
requestsInRange: u.requestsInRange,
requestsToday: u.requestsToday,
requestsThisMonth: u.requestsThisMonth,
lastRequestAt: u.lastRequestAt ?? null,
})),
daily: data.daily,
};
}
// ── One account ───────────────────────────────────────────────────────────────
/** A counter key is `tool:path`, or the bare path when the request carried no tool name. */
function splitEndpoint(endpoint: string): { tool: string; path: string } {
const colon = endpoint.indexOf(':');
return colon > 0 ? { tool: endpoint.slice(0, colon), path: endpoint.slice(colon + 1) } : { tool: NO_TOOL, path: endpoint };
}
const byRequestsThenName = <T extends { requests: number }>(name: (row: T) => string) =>
(a: T, b: T) => b.requests - a.requests || name(a).localeCompare(name(b));
/** One account's charged requests in the window, by tool (with the paths it requested) and by day. */
export function shapeAccount(data: GetMcpUserUsageResponse, window: { from: string; to: string }): Record<string, unknown> {
const tools = new Map<string, Map<string, number>>();
const days = new Map<string, number>();
let newest: (typeof data.records)[number] | undefined;
for (const record of data.records) {
const { tool, path } = splitEndpoint(record.endpoint);
const paths = tools.get(tool) ?? new Map<string, number>();
paths.set(path, (paths.get(path) ?? 0) + record.requestCount);
tools.set(tool, paths);
days.set(record.date, (days.get(record.date) ?? 0) + record.requestCount);
if (!newest || record.lastRequestAt > newest.lastRequestAt) newest = record;
}
const byTool = [...tools.entries()]
.map(([tool, paths]) => ({
tool,
requests: [...paths.values()].reduce((sum, n) => sum + n, 0),
paths: [...paths.entries()].map(([path, requests]) => ({ path, requests })).sort(byRequestsThenName((p) => p.path)),
}))
.sort(byRequestsThenName((t) => t.tool));
return {
view: 'account',
userId: data.userId,
principal: newest?.userPrincipalName || null,
tenantId: newest?.tenantId || null,
window,
requests: byTool.reduce((sum, t) => sum + t.requests, 0),
byTool,
byDay: [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, requests]) => ({ date, requests })),
};
}
@@ -326,7 +326,7 @@ function tenantIdFromNextLink(continuation?: string): string | undefined {
* nextLink's embedded tenantId when present (it is what actually gets sent), else the explicit arg, and
* validate THAT against the managed set — which also blocks a hand-crafted continuation pointing at an
* unmanaged tenant (defense in depth on top of the backend bound). No-op for non-delegated callers and
* for offset-only continuations (geo-offset:/inv-offset:/usage-offset:), which re-send the explicit
* for offset-only continuations (geo-offset:/inv-offset:), which re-send the explicit
* tenantId every page anyway.
*/
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {
@@ -2278,13 +2278,6 @@ export interface GetGlobalAdminsResponse {
admins: GlobalAdminRow[];
}
/** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */
export interface GetGlobalMcpUsageDailyResponse {
/** Echo of the request filter; absent when the caller passed no tenantId. */
tenantId?: string;
summaries: UserUsageDailySummary[];
}
/** The all-tenant MCP usage overview of the Global Admin metrics page (GetGlobalMcpUsageOverview) over whole UTC days: tenants with their accounts and quota state, the tool ranking, the quota refusals, the per-minute peaks and the daily trend. "Calls" are tool calls (request telemetry, one MCP correlation id per call); "requests" are backend requests charged to the quota (the quota counters). What a telemetry store did not answer is empty and named in Telemetry. */
export interface GetGlobalMcpUsageOverviewResponse {
/** First day of the window (yyyyMMdd, UTC). */
@@ -2294,6 +2287,8 @@ export interface GetGlobalMcpUsageOverviewResponse {
/** False when Global Admins and Global Readers are left out of every number. */
includesPlatformAdmins: boolean;
telemetry: McpUsageTelemetryStatus;
/** The window's totals over the lists below, computed once for every consumer. */
totals: McpUsageTotalsNode;
/** One item per day of the window, oldest first. */
daily: McpUsageDayItem[];
/** Every tenant with MCP use in the window, most calls first. */
@@ -2305,13 +2300,6 @@ export interface GetGlobalMcpUsageOverviewResponse {
minuteLimits: McpUsageMinuteLimits;
}
/** Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates over the records key — its name is wire-critical. */
export interface GetGlobalMcpUsageResponse {
/** Echo of the request filter; absent when the caller passed no tenantId. */
tenantId?: string;
records: UserUsageRecord[];
}
/** Response of GET tenants/{tenantId}/graph-permissions/status: the client id of the app homed for the tenant, the transient flag, the granted Graph app roles and the per-feature verdict matrix. */
export interface GetGraphPermissionsStatusResponse {
/** ClientId of the app registration that acts for this tenant (empty string when unresolved, never null). */
@@ -3434,6 +3422,8 @@ export interface McpUsageDayItem {
export interface McpUsageMinuteItem {
tenantId: string;
principal: string;
/** How close the account came to a per-minute limit, in whole percent: the higher of its call peak against the MCP server limit and its request peak against its backend limit (the list is ordered by it); 0 when neither limit is known. */
sharePercent: number;
/** Most tool calls the account started inside 60 seconds (measured in 10-second steps). */
peakCalls: number;
peakCallsAt?: string;
@@ -3528,6 +3518,8 @@ export interface McpUsageTelemetryStatus {
/** One tenant of the overview with its organization windows and its accounts. */
export interface McpUsageTenantItem {
tenantId: string;
/** The tenant's domain from its configuration; absent when it has none or the configuration was unreadable. */
domainName?: string;
/** The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters. */
quota: McpOrganizationQuotaNode;
calls: number;
@@ -3568,6 +3560,27 @@ export interface McpUsageToolItem {
routes: McpUsageRouteItem[];
}
/** The overview's totals, nested in GetGlobalMcpUsageOverviewResponse. "Calls" are tool calls, "requests" are backend requests charged to the quota; an account is a person or a service principal of one tenant. */
export interface McpUsageTotalsNode {
/** Tool calls in the window, MCP-only tools included; absent when the request telemetry did not answer. */
calls?: number;
/** Calls of tools that run in the MCP server alone (part of Calls, attributed to no account); absent without the MCP log. */
mcpOnlyCalls?: number;
/** Tools called in the window. */
tools: number;
requests: number;
tenants: number;
accounts: number;
/** Accounts that are service principals (app:<client-id>). */
servicePrincipals: number;
/** Accounts with at least one quota refusal in the window (daily or monthly, own or organization window). */
quotaHitAccounts: number;
/** Accounts with at least one per-minute refusal in the window (MCP server or backend). */
minuteRefusedAccounts: number;
/** Accounts that met any limit in the window — the union of the two above. */
limitAccounts: number;
}
export interface McpUserEntry {
upn: string;
isEnabled: boolean;
@@ -6295,15 +6308,6 @@ export interface UserMetrics {
note: string;
}
/** Aggregated daily usage summary across endpoints (and optionally users). */
export interface UserUsageDailySummary {
date: string;
tenantId?: string;
totalRequests: number;
uniqueUsers: number;
uniqueEndpoints: number;
}
/** A single usage record: one user, one day, one endpoint. */
export interface UserUsageRecord {
userId: string;
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { apiFetch, buildQuery, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, getCallerUpnDomain, getDelegatedTenantIds, getHomeTenantId, pageSizeForCall, pickGlobalOrTenantPath, scanWithTimeoutFallback, jsonBody } from '../client.js';
import { withToolTelemetry } from '../telemetry.js';
import { getResourceContent, withEventTypeNote, RESOURCE_NAMES } from '../resource-catalog.js';
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, ObjectIdSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
import { toolError } from './error-handler.js';
import { API_BASE_URL } from '../config.js';
import { collectDeploymentState } from '../deployment-state.js';
@@ -93,6 +93,7 @@ export function buildFleetOverview(
};
}
import { shapeVerdictCalibration } from '../verdict-calibration-shape.js';
import { OVERVIEW_VIEWS, shapeAccount, shapeOrganization, shapeOverview, usageWindow } from '../api-usage-shape.js';
import type {
AgentEfficiencyMetricsResponse,
AppMetricsResponse,
@@ -105,10 +106,9 @@ import type {
GeographicMetricsResponse,
GetAllTenantConfigurationsResponse,
GetDeviceHistoryResponse,
GetGlobalMcpUsageDailyResponse,
GetGlobalMcpUsageResponse,
GetGlobalMcpUsageOverviewResponse,
GetMcpOrganizationUsageResponse,
GetMcpUserUsageResponse,
GetMyMcpUsageResponse,
GetSessionTimeAttributionResponse,
GetTenantConfigFieldsSchemaResponse,
GetUnmatchedSoftwareResponse,
@@ -332,49 +332,6 @@ export function platformWindowEcho(
};
}
/**
* Page client-side over an MCP-usage payload. The mcp-usage endpoints return the
* full set in one shot (no server-side cursor): the global per-record breakdown
* alone can run to thousands of rows and overflow the inline response budget. We
* slice whichever array the payload carries ("records" for the per-user and
* global breakdowns, "summaries" for the daily view) and echo every other
* top-level field (tenantId/userId/usagePlan) verbatim. The "continuation"
* carries an integer offset (usage-offset:N); each page re-reads the same set,
* so raise pageSize to amortize round-trips on a full sweep. Mirrors the
* get_geographic_sessions client-side pager. Exported for unit testing.
*/
export function paginateUsage(
data: unknown,
pageSize: number,
continuation?: string,
): Record<string, unknown> {
if (!data || typeof data !== 'object') return { records: [], totalCount: 0, count: 0, offset: 0, nextLink: null };
const obj = data as Record<string, unknown>;
// The `satisfies` pins tie the sliced key names to the generated wire types —
// a backend rename of these wire-critical keys goes tsc-red here, not silent.
const arrayKey = Array.isArray(obj.records)
? ('records' satisfies keyof GetGlobalMcpUsageResponse & keyof GetMcpUserUsageResponse & keyof GetMyMcpUsageResponse)
: Array.isArray(obj.summaries)
? ('summaries' satisfies keyof GetGlobalMcpUsageDailyResponse)
: null;
if (!arrayKey) return obj; // unexpected shape — pass through untouched rather than mangle it
const all = obj[arrayKey] as unknown[];
const m = continuation ? /^usage-offset:(\d+)$/.exec(continuation) : null;
const offset = m ? parseInt(m[1], 10) : 0;
const slice = all.slice(offset, offset + pageSize);
const nextOffset = offset + pageSize;
const nextLink = nextOffset < all.length ? `usage-offset:${nextOffset}` : null;
const { records: _records, summaries: _summaries, ...rest } = obj;
return {
...rest,
totalCount: all.length,
count: slice.length,
offset,
[arrayKey]: slice,
nextLink,
};
}
/**
* Page client-side over a software-inventory payload. The inventory endpoints
* ({@code /api/metrics/software-inventory}, {@code /api/vulnerability/software-inventory})
@@ -383,7 +340,8 @@ export function paginateUsage(
* the {@code inventory} array and echo every other top-level field (success/tenantId/
* total/matched/unmatched) verbatim. The "continuation" carries an integer offset
* (inv-offset:N); each page re-reads the same set, so raise pageSize to amortize
* round-trips on a full sweep. Mirrors {@link paginateUsage}. Exported for unit testing.
* round-trips on a full sweep. Mirrors the get_geographic_sessions client-side pager.
* Exported for unit testing.
*/
export function paginateInventory(
data: unknown,
@@ -392,7 +350,7 @@ export function paginateInventory(
): Record<string, unknown> {
if (!data || typeof data !== 'object') return { inventory: [], total: 0, count: 0, offset: 0, nextLink: null };
const obj = data as Record<string, unknown>;
// Same wire-key pin as paginateUsage: renaming `inventory` on the wire goes tsc-red here.
// The `satisfies` pin ties the sliced key to the generated wire type: renaming `inventory` on the wire goes tsc-red here.
const inventoryKey = 'inventory' satisfies keyof SoftwareInventoryResponse;
if (!Array.isArray(obj[inventoryKey])) return obj; // unexpected shape — pass through untouched rather than mangle it
const all = obj[inventoryKey] as unknown[];
@@ -412,42 +370,61 @@ export function paginateInventory(
}
export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boolean = ga, delegated: boolean = false): void {
// Tool 11: get_api_usage — Global Admin only; not registered for normal users
// (the `if (ga)` guards the whole single server.registerTool(...) statement).
// Tool 11: get_api_usage — platform scope only (GA and Global Reader); not registered for tenant users
// (the `if (ga)` guards the whole single server.registerTool(...) statement). The shaping lives in
// api-usage-shape.ts; every figure is computed by the backend.
if (ga) server.registerTool(
'get_api_usage',
{
title: 'API Usage',
title: 'MCP Usage',
description:
'API/MCP usage statistics: request counts per endpoint per day. Use to monitor platform usage, identify ' +
'heavy users, or debug rate limiting. userId gives one user\'s view, daily=true a compact daily summary, ' +
'neither the global per-record breakdown (thousands of verbose rows — paged client-side over the full set, ' +
'so a larger pageSize saves round-trips). dateFrom/dateTo default to the full retention window.',
'MCP usage across all tenants, as on the GA page MCP Usage. A call is one tool call; a request is one backend ' +
'request charged to the daily/monthly quota (requestsPerCall = cost of a call). view=summary: totals, top tools ' +
'and tenants, newest quota refusals, accounts closest to a per-minute limit, each list cut to its first rows ' +
'(full lengths in `lists`). view=tools|tenants|quotaHits|minuteLimits|daily: that list in full. A quota hit is ' +
'one refused day per account or organization and window. Platform admins count only with ' +
'includePlatformAdmins=true. view=organization + tenantId: one tenant\'s budget by account and day (platform ' +
'admins included). view=account + userId: one account\'s requests by tool, path and day. Windows are ' +
'{used, limit}: limit 0 = unlimited, used null = unreadable. `telemetry` names a store that did not answer.',
inputSchema: {
userId: z.string().optional().describe('Specific user object ID to query usage for'),
tenantId: z.string().optional().describe('Filter usage by tenant ID'),
dateFrom: z.string().optional().describe('Start date (YYYY-MM-DD)'),
dateTo: z.string().optional().describe('End date (YYYY-MM-DD)'),
daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'),
pageSize: z.coerce.number().int().min(1).max(2000).optional().default(50)
.describe(pageSizeDescription(50, 2000)),
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
view: z.enum([...OVERVIEW_VIEWS, 'organization', 'account']).optional().default('summary')
.describe('Default summary. organization needs tenantId, account needs userId.'),
days: z.coerce.number().int().min(1).max(90).optional().default(30)
.describe(daysDescription(30, 90)),
includePlatformAdmins: z.boolean().optional().default(false)
.describe('Overview views only: count Global Admins and Global Readers too.'),
tenantId: TenantGuidSchema.optional().describe('The tenant of view=organization.'),
userId: ObjectIdSchema.optional().describe('The account of view=account: its Entra object id (view=tenants lists them).'),
},
annotations: READ_ONLY,
},
async (args) => withToolTelemetry('get_api_usage', args, async () => {
try {
let data: GetMcpUserUsageResponse | GetGlobalMcpUsageDailyResponse | GetGlobalMcpUsageResponse;
const params: Record<string, string | undefined> = { tenantId: args.tenantId, dateFrom: args.dateFrom, dateTo: args.dateTo };
if (args.userId) {
data = await apiFetch<GetMcpUserUsageResponse>(`/api/metrics/mcp-usage/user/${encodeURIComponent(args.userId)}${buildQuery(params)}`);
} else if (args.daily) {
data = await apiFetch<GetGlobalMcpUsageDailyResponse>(`/api/global/metrics/mcp-usage/daily${buildQuery(params)}`);
} else {
data = await apiFetch<GetGlobalMcpUsageResponse>(`/api/global/metrics/mcp-usage${buildQuery(params)}`);
const view = args.view;
const window = usageWindow(args.days);
if (view === 'organization') {
if (!args.tenantId) {
return toolError('get_api_usage', args, new Error(
'tenantId is required for view=organization; list_tenants resolves a tenant name to its id.'));
}
const data = await apiFetch<GetMcpOrganizationUsageResponse>(
`/api/global/metrics/mcp-usage/organization${buildQuery({ tenantId: args.tenantId, dateFrom: window.from, dateTo: window.to })}`);
return toolResultText(shapeOrganization(data), MAX_RESULT_SIZE_CHARS.adminStream);
}
return toolResultText(paginateUsage(data, args.pageSize, args.continuation), MAX_RESULT_SIZE_CHARS.adminStream);
if (view === 'account') {
if (!args.userId) {
return toolError('get_api_usage', args, new Error(
'userId (the Entra object id) is required for view=account; view=tenants lists every account with its userId.'));
}
const data = await apiFetch<GetMcpUserUsageResponse>(
`/api/metrics/mcp-usage/user/${encodeURIComponent(args.userId)}${buildQuery({ dateFrom: window.from, dateTo: window.to })}`);
return toolResultText(shapeAccount(data, window), MAX_RESULT_SIZE_CHARS.adminStream);
}
// Seven telemetry queries with a 60-s budget each run behind this route: outlast them.
const data = await apiFetch<GetGlobalMcpUsageOverviewResponse>(
`/api/global/metrics/mcp-usage/overview${buildQuery({ days: args.days, platformAdmins: args.includePlatformAdmins ? 'include' : 'exclude' })}`,
{ signal: AbortSignal.timeout(90_000) });
return toolResultText(shapeOverview(data, view), MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) {
return toolError('get_api_usage', args, error);
}
@@ -92,12 +92,11 @@ export const SUMMARY_EVENT_FIELDS =
* to a different endpoint. The strict GUID pattern blocks both that path-
* traversal vector and accidental garbage inputs.
*/
const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const SessionIdSchema = z
.string()
.regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
'sessionId must be a UUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")',
);
.regex(GUID_PATTERN, 'sessionId must be a UUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")');
/**
* Read-side guard for the historical / in-flight `script_failed` rows that the agent
@@ -192,10 +191,16 @@ export function tenantIdDescription(
*/
export const TenantGuidSchema = z
.string()
.regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
'tenantId must be a GUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")',
);
.regex(GUID_PATTERN, 'tenantId must be a GUID (e.g. "e259c121-1234-4abc-9def-0123456789ab")');
/**
* Zod validator for an account's Entra object id interpolated into a backend URL path
* (`/api/metrics/mcp-usage/user/{userId}`) — same path-traversal rationale as SessionIdSchema. It also
* turns a UPN passed by mistake into an error instead of an empty answer that reads as "no usage".
*/
export const ObjectIdSchema = z
.string()
.regex(GUID_PATTERN, 'userId must be an Entra object id (a GUID such as "e259c121-1234-4abc-9def-0123456789ab"), not a UPN');
/** Read-only query tool — no side effects, idempotent, closed-world (our backend only). */
export const READ_ONLY: ToolAnnotations = {
@@ -8,9 +8,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -28,7 +28,7 @@
<ItemGroup>
<!-- Plan §2.8 — Newtonsoft.Json on both sides (Agent net48 + Backend net8.0) for DecisionCore DTOs. -->
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
</Project>
@@ -19,7 +19,7 @@
</PropertyGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
<PackageReference Include="System.Text.Json" Version="9.0.1" />
<PackageReference Include="System.Text.Json" Version="10.0.14" />
</ItemGroup>
<ItemGroup>
@@ -21,16 +21,6 @@ namespace AutopilotMonitor.Shared.DataAccess
/// </summary>
Task<List<UserUsageRecord>> GetUsageByUserAsync(string userId, string? dateFrom = null, string? dateTo = null);
/// <summary>
/// Gets usage records for all users belonging to a tenant within an optional date range.
/// </summary>
Task<List<UserUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null);
/// <summary>
/// Gets aggregated daily usage summaries, optionally filtered by tenant.
/// </summary>
Task<List<UserUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null);
/// <summary>
/// Deletes all usage records older than the specified date (yyyyMMdd format).
/// Returns the number of records deleted.
@@ -84,16 +74,4 @@ namespace AutopilotMonitor.Shared.DataAccess
public long RequestCount { get; set; }
public DateTime LastRequestAt { get; set; }
}
/// <summary>
/// Aggregated daily usage summary across endpoints (and optionally users).
/// </summary>
public class UserUsageDailySummary
{
public string Date { get; set; } = string.Empty;
public string? TenantId { get; set; }
public long TotalRequests { get; set; }
public int UniqueUsers { get; set; }
public int UniqueEndpoints { get; set; }
}
}
@@ -216,6 +216,8 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>False when Global Admins and Global Readers are left out of every number.</summary>
public bool IncludesPlatformAdmins { get; set; }
public McpUsageTelemetryStatus Telemetry { get; set; } = default!;
/// <summary>The window's totals over the lists below, computed once for every consumer.</summary>
public McpUsageTotalsNode Totals { get; set; } = default!;
/// <summary>One item per day of the window, oldest first.</summary>
public IReadOnlyList<McpUsageDayItem> Daily { get; set; } = default!;
/// <summary>Every tenant with MCP use in the window, most calls first.</summary>
@@ -241,6 +243,33 @@ namespace AutopilotMonitor.Shared.Models
public bool? Partial { get; set; }
}
/// <summary>
/// The overview's totals, nested in <see cref="GetGlobalMcpUsageOverviewResponse"/>. "Calls" are tool calls,
/// "requests" are backend requests charged to the quota; an account is a person or a service principal of one
/// tenant.
/// </summary>
// Declaration order == wire order.
public class McpUsageTotalsNode
{
/// <summary>Tool calls in the window, MCP-only tools included; absent when the request telemetry did not answer.</summary>
public long? Calls { get; set; }
/// <summary>Calls of tools that run in the MCP server alone (part of <see cref="Calls"/>, attributed to no account); absent without the MCP log.</summary>
public long? McpOnlyCalls { get; set; }
/// <summary>Tools called in the window.</summary>
public int Tools { get; set; }
public long Requests { get; set; }
public int Tenants { get; set; }
public int Accounts { get; set; }
/// <summary>Accounts that are service principals (<c>app:&lt;client-id&gt;</c>).</summary>
public int ServicePrincipals { get; set; }
/// <summary>Accounts with at least one quota refusal in the window (daily or monthly, own or organization window).</summary>
public int QuotaHitAccounts { get; set; }
/// <summary>Accounts with at least one per-minute refusal in the window (MCP server or backend).</summary>
public int MinuteRefusedAccounts { get; set; }
/// <summary>Accounts that met any limit in the window — the union of the two above.</summary>
public int LimitAccounts { get; set; }
}
/// <summary>One day of the overview trend.</summary>
// Declaration order == wire order.
public class McpUsageDayItem
@@ -268,6 +297,8 @@ namespace AutopilotMonitor.Shared.Models
public class McpUsageTenantItem
{
public string TenantId { get; set; } = default!;
/// <summary>The tenant's domain from its configuration; absent when it has none or the configuration was unreadable.</summary>
public string? DomainName { get; set; }
/// <summary>The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters.</summary>
public McpOrganizationQuotaNode Quota { get; set; } = default!;
public long Calls { get; set; }
@@ -399,6 +430,11 @@ namespace AutopilotMonitor.Shared.Models
{
public string TenantId { get; set; } = default!;
public string Principal { get; set; } = default!;
/// <summary>
/// How close the account came to a per-minute limit, in whole percent: the higher of its call peak against the MCP
/// server limit and its request peak against its backend limit (the list is ordered by it); 0 when neither limit is known.
/// </summary>
public int SharePercent { get; set; }
/// <summary>Most tool calls the account started inside 60 seconds (measured in 10-second steps).</summary>
public long PeakCalls { get; set; }
public DateTime? PeakCallsAt { get; set; }
@@ -415,32 +451,6 @@ namespace AutopilotMonitor.Shared.Models
public DateTime? BackendLastRefusedAt { get; set; }
}
/// <summary>
/// Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates
/// over the <c>records</c> key — its name is wire-critical.
/// </summary>
// Declaration order == wire order.
public class GetGlobalMcpUsageResponse : IApiResponse
{
/// <summary>Echo of the request filter; absent when the caller passed no tenantId.</summary>
public string? TenantId { get; set; }
public IReadOnlyList<UserUsageRecord> Records { get; set; } = default!;
}
/// <summary>
/// Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server
/// paginates over the <c>summaries</c> key — its name is wire-critical.
/// </summary>
// Declaration order == wire order.
public class GetGlobalMcpUsageDailyResponse : IApiResponse
{
/// <summary>Echo of the request filter; absent when the caller passed no tenantId.</summary>
public string? TenantId { get; set; }
public IReadOnlyList<UserUsageDailySummary> Summaries { get; set; } = default!;
}
/// <summary>
/// Per-tenant session-status tally envelope shared by MetricsSummary and
/// MetricsSummaryGlobal.
@@ -20,10 +20,10 @@ import type {
import { Stat } from "./Stat";
import {
dailyChart,
editionCounts,
formatDayKey,
MCP_USAGE_MAX_DAYS,
MCP_USAGE_VIEW_PARAM,
overviewTotals,
parseView,
planLegend,
tenantInfoMap,
@@ -137,9 +137,10 @@ function McpUsageContent() {
() => (tenantRows ? tenantInfoMap(tenantRows.tenants, tenantRows.nowMs) : NO_TENANT_INFO),
[tenantRows],
);
const totals = useMemo(() => (overview ? overviewTotals(overview, info) : null), [overview, info]);
const editions = useMemo(() => (overview ? editionCounts(overview, info) : null), [overview, info]);
const chart = useMemo(() => (overview ? dailyChart(overview) : null), [overview]);
const plans = useMemo(() => planLegend(planTiers), [planTiers]);
const totals = overview?.totals;
const viewOptions: { value: McpUsageView; label: string }[] = [
{ value: "tenants", label: "Tenants & accounts" },
@@ -147,8 +148,6 @@ function McpUsageContent() {
{ value: "limits", label: totals && totals.limitAccounts > 0 ? `Limits (${totals.limitAccounts})` : "Limits" },
];
const telemetryDark = overview && !overview.telemetry.requestsAvailable && !overview.telemetry.mcpLogAvailable;
return (
// Self-provides the max-w container the metrics layout used to supply.
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
@@ -195,20 +194,20 @@ function McpUsageContent() {
estimateMs={estimateMs}
/>
</div>
) : overview && totals && chart ? (
) : overview && totals && editions && chart ? (
<>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
<Stat
label="Tool calls"
value={telemetryDark ? "—" : totals.calls.toLocaleString()}
hint={totals.mcpOnlyCalls > 0 ? `${totals.toolCount} tools · ${totals.mcpOnlyCalls.toLocaleString()} MCP-only` : `${totals.toolCount} tools`}
value={totals.calls == null ? "—" : totals.calls.toLocaleString()}
hint={totals.mcpOnlyCalls ? `${totals.tools} tools · ${totals.mcpOnlyCalls.toLocaleString()} MCP-only` : `${totals.tools} tools`}
/>
<Stat label="Requests" value={totals.requests.toLocaleString()} hint="charged to the quota" />
<Stat label="Tenants" value={totals.tenants.toLocaleString()} hint={`${totals.communityTenants} Community · ${totals.proTenants} Pro`} />
<Stat label="Tenants" value={totals.tenants.toLocaleString()} hint={`${editions.community} Community · ${editions.pro} Pro`} />
<Stat
label="Accounts"
value={totals.accounts.toLocaleString()}
hint={`${totals.accounts - totals.apps} people · ${totals.apps} ${totals.apps === 1 ? "app" : "apps"}`}
hint={`${totals.accounts - totals.servicePrincipals} people · ${totals.servicePrincipals} ${totals.servicePrincipals === 1 ? "app" : "apps"}`}
/>
<button
type="button"
@@ -15,9 +15,8 @@ import {
formatDayKey,
formatDuration,
formatShare,
editionCounts,
minuteRowsToShow,
minuteShare,
overviewTotals,
parseView,
planLegend,
searchTenants,
@@ -154,6 +153,10 @@ function overview(over: Partial<GetGlobalMcpUsageOverviewResponse> = {}): GetGlo
dateTo: "20261105",
includesPlatformAdmins: false,
telemetry: { requestsAvailable: true, mcpLogAvailable: true },
totals: {
calls: 0, mcpOnlyCalls: 0, tools: 0, requests: 0, tenants: 0, accounts: 0, servicePrincipals: 0,
quotaHitAccounts: 0, minuteRefusedAccounts: 0, limitAccounts: 0,
},
daily: [],
tenants: [],
tools: [],
@@ -168,31 +171,15 @@ function tool(name: string, calls: number, over: Partial<McpUsageToolItem> = {})
}
function minute(principal: string, over: Partial<McpUsageMinuteItem> = {}): McpUsageMinuteItem {
return { tenantId: TENANT_A, principal, peakCalls: 0, mcpRefused: 0, peakRequests: 0, backendLimit: 120, backendRefused: 0, ...over };
return { tenantId: TENANT_A, principal, sharePercent: 0, peakCalls: 0, mcpRefused: 0, peakRequests: 0, backendLimit: 120, backendRefused: 0, ...over };
}
describe("overviewTotals", () => {
it("counts MCP-only calls in the total and every limited account once", () => {
const totals = overviewTotals(
overview({
tenants: [tenant(TENANT_A, [account("alice@contoso.com"), account("app:11111111-2222-3333-4444-555555555555")]), tenant(TENANT_B, [account("bob@fabrikam.com")])],
tools: [tool("get_session_summary", 10), tool("search_docs", 4, { accounts: undefined, tenants: undefined, requestsPerCall: 0 })],
quotaHits: [
{ date: "20261105", tenantId: TENANT_A, principal: "alice@contoso.com", level: "user", scope: "daily", plan: "community", refused: 3, firstAt: "", lastAt: "" },
{ date: "20261104", tenantId: TENANT_A, principal: "alice@contoso.com", level: "user", scope: "daily", plan: "community", refused: 1, firstAt: "", lastAt: "" },
],
minuteLimits: { accounts: [minute("alice@contoso.com", { backendRefused: 2 }), minute("bob@fabrikam.com", { tenantId: TENANT_B, mcpRefused: 7 })] },
}),
INFO,
);
expect(totals.calls).toBe(14);
expect(totals.mcpOnlyCalls).toBe(4);
expect(totals.toolCount).toBe(2);
expect(totals.requests).toBe(30);
expect([totals.tenants, totals.communityTenants, totals.proTenants]).toEqual([2, 1, 1]);
expect([totals.accounts, totals.apps]).toEqual([3, 1]);
expect([totals.limitAccounts, totals.quotaHitAccounts, totals.minuteRefusedAccounts]).toEqual([2, 1, 2]);
describe("editionCounts", () => {
it("counts the overview's tenants by their edition from config/all", () => {
const tenants = [tenant(TENANT_A, [account("alice@contoso.com")]), tenant(TENANT_B, [account("bob@fabrikam.com")])];
expect(editionCounts(overview({ tenants }), INFO)).toEqual({ community: 1, pro: 1 });
// A tenant missing from config/all counts toward neither edition.
expect(editionCounts(overview({ tenants: [tenant("cccccccc-0000-0000-0000-00000000000c", [])] }), INFO)).toEqual({ community: 0, pro: 0 });
});
});
@@ -213,20 +200,13 @@ describe("dailyChart", () => {
});
describe("per-minute rows", () => {
it("takes the higher share of the two limits", () => {
expect(minuteShare(minute("a", { peakCalls: 30, peakRequests: 108 }), 60)).toBe(90);
expect(minuteShare(minute("a", { peakCalls: 45, peakRequests: 12 }), 60)).toBe(75);
// No MCP limit reported yet: only the backend share counts.
expect(minuteShare(minute("a", { peakCalls: 59, peakRequests: 12 }), undefined)).toBe(10);
});
it("shows a refusal at any peak, else a peak at half a limit", () => {
const rows = [
minute("low", { peakCalls: 5, peakRequests: 10 }),
minute("refused", { peakRequests: 10, backendRefused: 1 }),
minute("close", { peakCalls: 30 }),
minute("low", { sharePercent: 9 }),
minute("refused", { sharePercent: 8, backendRefused: 1 }),
minute("close", { sharePercent: 50 }),
];
expect(minuteRowsToShow(rows, 60).map((r) => r.principal)).toEqual(["refused", "close"]);
expect(minuteRowsToShow(rows).map((r) => r.principal)).toEqual(["refused", "close"]);
});
});
@@ -8,7 +8,7 @@ import type {
McpUsageQuotaHitItem,
McpUsageTelemetryStatus,
} from "@/lib/generated/wire-types.generated";
import { formatDayKey, minuteRowsToShow, minuteShare, tenantLabel, type PlanLegendRow, type TenantInfo } from "./mcpUsageLogic";
import { formatDayKey, minuteRowsToShow, tenantLabel, type PlanLegendRow, type TenantInfo } from "./mcpUsageLogic";
import { DataNote, Pill } from "./parts";
function limitText(value: number | undefined): string {
@@ -135,7 +135,7 @@ function MinuteSection({
}) {
const [showAll, setShowAll] = useState(false);
const mcpLimit = minuteLimits.mcpServerLimit;
const close = minuteRowsToShow(minuteLimits.accounts, mcpLimit);
const close = minuteRowsToShow(minuteLimits.accounts);
const rows = showAll ? minuteLimits.accounts : close;
return (
@@ -170,7 +170,7 @@ function MinuteSection({
<div className="min-w-0 text-sm text-gray-900">
<Principal principal={row.principal} />
<div className="text-xs text-gray-500">
{tenantLabel(row.tenantId, info)} · busiest 60 s at {minuteShare(row, mcpLimit)} % of a limit
{tenantLabel(row.tenantId, info)} · busiest 60 s at {row.sharePercent} % of a limit
</div>
</div>
<div>
@@ -5,7 +5,6 @@
* quota — the page never mixes the two in one figure.
*/
import { effectiveEdition, type PlanEdition } from "../../../components/tenantFilters";
import { isApplicationKey } from "@/lib/principalKeys";
import type {
GetGlobalMcpUsageOverviewResponse,
McpUsageAccountItem,
@@ -183,45 +182,20 @@ export function searchTenants(
return result;
}
// ── Totals (the tiles) ────────────────────────────────────────────────────────
// ── Editions (the Tenants tile) ───────────────────────────────────────────────
export interface OverviewTotals {
calls: number;
/** Calls of tools that run in the MCP server alone — counted in `calls`, attributed to no account. */
mcpOnlyCalls: number;
toolCount: number;
requests: number;
tenants: number;
communityTenants: number;
proTenants: number;
accounts: number;
apps: number;
/** Accounts that met any limit in the window. */
limitAccounts: number;
quotaHitAccounts: number;
minuteRefusedAccounts: number;
}
export function overviewTotals(overview: GetGlobalMcpUsageOverviewResponse, info: ReadonlyMap<string, TenantInfo>): OverviewTotals {
const accounts = overview.tenants.flatMap((t) => t.accounts);
/**
* The overview's tenants by edition, trials resolved from config/all. The other tile figures come from
* `overview.totals`, which the backend computes once for every consumer; the edition is the one the page adds.
*/
export function editionCounts(
overview: GetGlobalMcpUsageOverviewResponse,
info: ReadonlyMap<string, TenantInfo>,
): { community: number; pro: number } {
const editionOf = (tenantId: string) => info.get(tenantId.toLowerCase())?.edition;
const quotaHit = new Set(overview.quotaHits.map((h) => `${h.tenantId}|${h.principal ?? ""}`));
const minuteRefused = new Set(
overview.minuteLimits.accounts.filter((m) => m.mcpRefused > 0 || m.backendRefused > 0).map((m) => `${m.tenantId}|${m.principal}`),
);
return {
calls: overview.tools.reduce((sum, t) => sum + t.calls, 0),
mcpOnlyCalls: overview.tools.filter((t) => t.accounts == null).reduce((sum, t) => sum + t.calls, 0),
toolCount: overview.tools.length,
requests: overview.tenants.reduce((sum, t) => sum + t.requests, 0),
tenants: overview.tenants.length,
communityTenants: overview.tenants.filter((t) => editionOf(t.tenantId) === "community").length,
proTenants: overview.tenants.filter((t) => editionOf(t.tenantId)?.startsWith("pro")).length,
accounts: accounts.length,
apps: accounts.filter((a) => isApplicationKey(a.principal)).length,
limitAccounts: new Set([...quotaHit, ...minuteRefused]).size,
quotaHitAccounts: quotaHit.size,
minuteRefusedAccounts: minuteRefused.size,
community: overview.tenants.filter((t) => editionOf(t.tenantId) === "community").length,
pro: overview.tenants.filter((t) => editionOf(t.tenantId)?.startsWith("pro")).length,
};
}
@@ -251,16 +225,9 @@ export function dailyChart(overview: GetGlobalMcpUsageOverviewResponse): DailyCh
/** A peak counts as close from here; below it an account only shows with a refusal. */
export const MINUTE_SHOW_PERCENT = 50;
/** The higher of an account's two per-minute shares (MCP server, backend), in whole percent. */
export function minuteShare(row: McpUsageMinuteItem, mcpLimit: number | undefined): number {
const mcp = mcpLimit && mcpLimit > 0 ? (row.peakCalls / mcpLimit) * 100 : 0;
const backend = row.backendLimit > 0 ? (row.peakRequests / row.backendLimit) * 100 : 0;
return Math.round(Math.max(mcp, backend));
}
/** Rows worth a look: a refusal, or a peak at half a limit or more. */
export function minuteRowsToShow(rows: readonly McpUsageMinuteItem[], mcpLimit: number | undefined): McpUsageMinuteItem[] {
return rows.filter((r) => r.mcpRefused > 0 || r.backendRefused > 0 || minuteShare(r, mcpLimit) >= MINUTE_SHOW_PERCENT);
/** Rows worth a look: a refusal, or a peak at half a limit or more (`sharePercent`, computed by the backend). */
export function minuteRowsToShow(rows: readonly McpUsageMinuteItem[]): McpUsageMinuteItem[] {
return rows.filter((r) => r.mcpRefused > 0 || r.backendRefused > 0 || r.sharePercent >= MINUTE_SHOW_PERCENT);
}
// ── Plan legend ───────────────────────────────────────────────────────────────
-2
View File
@@ -826,8 +826,6 @@ export const api = {
mcpUsage: {
me: (dateFrom?: string, dateTo?: string) =>
`${API_BASE_URL}/api/metrics/mcp-usage/me${qs({ dateFrom, dateTo })}`,
user: (userId: string, dateFrom?: string, dateTo?: string) =>
`${API_BASE_URL}/api/metrics/mcp-usage/user/${encodeURIComponent(userId)}${qs({ dateFrom, dateTo })}`,
/** Own tenant's organization budget by account (its members and service principals; delegated reads into it are charged to the reader's home tenant). Tenant Admin / Global Reader. */
organization: (dateFrom?: string, dateTo?: string) =>
`${API_BASE_URL}/api/metrics/mcp-usage/organization${qs({ dateFrom, dateTo })}`,
@@ -10936,31 +10936,6 @@
}
]
},
"GetGlobalMcpUsageDailyResponse": {
"kind": "object",
"doc": "Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical.",
"fields": [
{
"name": "tenantId",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Echo of the request filter; absent when the caller passed no tenantId."
},
{
"name": "summaries",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "UserUsageDailySummary"
}
}
}
]
},
"GetGlobalMcpUsageOverviewResponse": {
"kind": "object",
"doc": "The all-tenant MCP usage overview of the Global Admin metrics page (GetGlobalMcpUsageOverview) over whole UTC days: tenants with their accounts and quota state, the tool ranking, the quota refusals, the per-minute peaks and the daily trend. \"Calls\" are tool calls (request telemetry, one MCP correlation id per call); \"requests\" are backend requests charged to the quota (the quota counters). What a telemetry store did not answer is empty and named in Telemetry.",
@@ -10996,6 +10971,14 @@
"name": "McpUsageTelemetryStatus"
}
},
{
"name": "totals",
"type": {
"kind": "ref",
"name": "McpUsageTotalsNode"
},
"doc": "The window's totals over the lists below, computed once for every consumer."
},
{
"name": "daily",
"type": {
@@ -11049,31 +11032,6 @@
}
]
},
"GetGlobalMcpUsageResponse": {
"kind": "object",
"doc": "Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates over the records key — its name is wire-critical.",
"fields": [
{
"name": "tenantId",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "Echo of the request filter; absent when the caller passed no tenantId."
},
{
"name": "records",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "UserUsageRecord"
}
}
}
]
},
"GetGraphPermissionsStatusResponse": {
"kind": "object",
"doc": "Response of GET tenants/{tenantId}/graph-permissions/status: the client id of the app homed for the tenant, the transient flag, the granted Graph app roles and the per-feature verdict matrix.",
@@ -16156,6 +16114,14 @@
"name": "string"
}
},
{
"name": "sharePercent",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "How close the account came to a per-minute limit, in whole percent: the higher of its call peak against the MCP server limit and its request peak against its backend limit (the list is ordered by it); 0 when neither limit is known."
},
{
"name": "peakCalls",
"type": {
@@ -16558,6 +16524,15 @@
"name": "string"
}
},
{
"name": "domainName",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "The tenant's domain from its configuration; absent when it has none or the configuration was unreadable."
},
{
"name": "quota",
"type": {
@@ -16723,6 +16698,91 @@
}
]
},
"McpUsageTotalsNode": {
"kind": "object",
"doc": "The overview's totals, nested in GetGlobalMcpUsageOverviewResponse. \"Calls\" are tool calls, \"requests\" are backend requests charged to the quota; an account is a person or a service principal of one tenant.",
"fields": [
{
"name": "calls",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Tool calls in the window, MCP-only tools included; absent when the request telemetry did not answer."
},
{
"name": "mcpOnlyCalls",
"type": {
"kind": "primitive",
"name": "number"
},
"optional": true,
"doc": "Calls of tools that run in the MCP server alone (part of Calls, attributed to no account); absent without the MCP log."
},
{
"name": "tools",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Tools called in the window."
},
{
"name": "requests",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "tenants",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "accounts",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "servicePrincipals",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Accounts that are service principals (app:<client-id>)."
},
{
"name": "quotaHitAccounts",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Accounts with at least one quota refusal in the window (daily or monthly, own or organization window)."
},
{
"name": "minuteRefusedAccounts",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Accounts with at least one per-minute refusal in the window (MCP server or backend)."
},
{
"name": "limitAccounts",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Accounts that met any limit in the window — the union of the two above."
}
]
},
"McpUserEntry": {
"kind": "object",
"fields": [
@@ -28605,48 +28665,6 @@
}
]
},
"UserUsageDailySummary": {
"kind": "object",
"doc": "Aggregated daily usage summary across endpoints (and optionally users).",
"fields": [
{
"name": "date",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "tenantId",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true
},
{
"name": "totalRequests",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "uniqueUsers",
"type": {
"kind": "primitive",
"name": "number"
}
},
{
"name": "uniqueEndpoints",
"type": {
"kind": "primitive",
"name": "number"
}
}
]
},
"UserUsageRecord": {
"kind": "object",
"doc": "A single usage record: one user, one day, one endpoint.",
@@ -2278,13 +2278,6 @@ export interface GetGlobalAdminsResponse {
admins: GlobalAdminRow[];
}
/** Global daily MCP/API usage summaries envelope (GetGlobalMcpUsageDaily). The MCP server paginates over the summaries key — its name is wire-critical. */
export interface GetGlobalMcpUsageDailyResponse {
/** Echo of the request filter; absent when the caller passed no tenantId. */
tenantId?: string;
summaries: UserUsageDailySummary[];
}
/** The all-tenant MCP usage overview of the Global Admin metrics page (GetGlobalMcpUsageOverview) over whole UTC days: tenants with their accounts and quota state, the tool ranking, the quota refusals, the per-minute peaks and the daily trend. "Calls" are tool calls (request telemetry, one MCP correlation id per call); "requests" are backend requests charged to the quota (the quota counters). What a telemetry store did not answer is empty and named in Telemetry. */
export interface GetGlobalMcpUsageOverviewResponse {
/** First day of the window (yyyyMMdd, UTC). */
@@ -2294,6 +2287,8 @@ export interface GetGlobalMcpUsageOverviewResponse {
/** False when Global Admins and Global Readers are left out of every number. */
includesPlatformAdmins: boolean;
telemetry: McpUsageTelemetryStatus;
/** The window's totals over the lists below, computed once for every consumer. */
totals: McpUsageTotalsNode;
/** One item per day of the window, oldest first. */
daily: McpUsageDayItem[];
/** Every tenant with MCP use in the window, most calls first. */
@@ -2305,13 +2300,6 @@ export interface GetGlobalMcpUsageOverviewResponse {
minuteLimits: McpUsageMinuteLimits;
}
/** Global per-tenant MCP/API usage envelope (GetGlobalMcpUsage). The MCP server paginates over the records key — its name is wire-critical. */
export interface GetGlobalMcpUsageResponse {
/** Echo of the request filter; absent when the caller passed no tenantId. */
tenantId?: string;
records: UserUsageRecord[];
}
/** Response of GET tenants/{tenantId}/graph-permissions/status: the client id of the app homed for the tenant, the transient flag, the granted Graph app roles and the per-feature verdict matrix. */
export interface GetGraphPermissionsStatusResponse {
/** ClientId of the app registration that acts for this tenant (empty string when unresolved, never null). */
@@ -3434,6 +3422,8 @@ export interface McpUsageDayItem {
export interface McpUsageMinuteItem {
tenantId: string;
principal: string;
/** How close the account came to a per-minute limit, in whole percent: the higher of its call peak against the MCP server limit and its request peak against its backend limit (the list is ordered by it); 0 when neither limit is known. */
sharePercent: number;
/** Most tool calls the account started inside 60 seconds (measured in 10-second steps). */
peakCalls: number;
peakCallsAt?: string;
@@ -3528,6 +3518,8 @@ export interface McpUsageTelemetryStatus {
/** One tenant of the overview with its organization windows and its accounts. */
export interface McpUsageTenantItem {
tenantId: string;
/** The tenant's domain from its configuration; absent when it has none or the configuration was unreadable. */
domainName?: string;
/** The organization windows (tenant plan, limits, today / this month) from the tenant's quota counters. */
quota: McpOrganizationQuotaNode;
calls: number;
@@ -3568,6 +3560,27 @@ export interface McpUsageToolItem {
routes: McpUsageRouteItem[];
}
/** The overview's totals, nested in GetGlobalMcpUsageOverviewResponse. "Calls" are tool calls, "requests" are backend requests charged to the quota; an account is a person or a service principal of one tenant. */
export interface McpUsageTotalsNode {
/** Tool calls in the window, MCP-only tools included; absent when the request telemetry did not answer. */
calls?: number;
/** Calls of tools that run in the MCP server alone (part of Calls, attributed to no account); absent without the MCP log. */
mcpOnlyCalls?: number;
/** Tools called in the window. */
tools: number;
requests: number;
tenants: number;
accounts: number;
/** Accounts that are service principals (app:<client-id>). */
servicePrincipals: number;
/** Accounts with at least one quota refusal in the window (daily or monthly, own or organization window). */
quotaHitAccounts: number;
/** Accounts with at least one per-minute refusal in the window (MCP server or backend). */
minuteRefusedAccounts: number;
/** Accounts that met any limit in the window — the union of the two above. */
limitAccounts: number;
}
export interface McpUserEntry {
upn: string;
isEnabled: boolean;
@@ -6295,15 +6308,6 @@ export interface UserMetrics {
note: string;
}
/** Aggregated daily usage summary across endpoints (and optionally users). */
export interface UserUsageDailySummary {
date: string;
tenantId?: string;
totalRequests: number;
uniqueUsers: number;
uniqueEndpoints: number;
}
/** A single usage record: one user, one day, one endpoint. */
export interface UserUsageRecord {
userId: string;
+13 -13
View File
@@ -33,8 +33,8 @@
"autoprefixer": "^10.6.2",
"eslint": "^9.39.5",
"eslint-config-next": "^16.4.3",
"knip": "^6.43.0",
"postcss": "^8.5.12",
"knip": "^6.44.0",
"postcss": "^8.5.32",
"tailwindcss": "^3.4.19",
"typescript": "^5.7.3",
"vitest": "^4.1.12"
@@ -6489,9 +6489,9 @@
}
},
"node_modules/knip": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.43.0.tgz",
"integrity": "sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==",
"version": "6.44.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
"dev": true,
"funding": [
{
@@ -7031,9 +7031,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"funding": [
{
"type": "github",
@@ -7623,9 +7623,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.25",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
"version": "8.5.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
"funding": [
{
"type": "opencollective",
@@ -7642,9 +7642,9 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"nanoid": "^3.3.20",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
+2 -2
View File
@@ -44,8 +44,8 @@
"autoprefixer": "^10.6.2",
"eslint": "^9.39.5",
"eslint-config-next": "^16.4.3",
"knip": "^6.43.0",
"postcss": "^8.5.12",
"knip": "^6.44.0",
"postcss": "^8.5.32",
"tailwindcss": "^3.4.19",
"typescript": "^5.7.3",
"vitest": "^4.1.12"
+4 -4
View File
@@ -6,7 +6,7 @@
"": {
"name": "autopilot-monitor-swa-cli",
"devDependencies": {
"@azure/static-web-apps-cli": "2.0.10"
"@azure/static-web-apps-cli": "2.1.0"
}
},
"node_modules/@azure/abort-controller": {
@@ -420,9 +420,9 @@
}
},
"node_modules/@azure/static-web-apps-cli": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@azure/static-web-apps-cli/-/static-web-apps-cli-2.0.10.tgz",
"integrity": "sha512-AtZj15sG5/qWkqY8l8IyVQpL3jQaBn2V8TcjFLuwJJYZVh0l3Q+o4G6dTt/V5VWiXjyrlnyhJfHC8wygTzMIvw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@azure/static-web-apps-cli/-/static-web-apps-cli-2.1.0.tgz",
"integrity": "sha512-RUbHhLaGDQAbW0d0J3HzKIT3jumQtIaQF2g/qiTa1GeWO9/aasdn5k5WOWD+rpL+eUc+LjM1R/xbQpvbp7j3gg==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"description": "Azure Static Web Apps CLI for the deploy job of deploy-web.yml - installed from this lockfile with --ignore-scripts, because that job holds the production deploy token. The overrides lift adm-zip and tmp above versions with known advisories that the CLI still pins.",
"devDependencies": {
"@azure/static-web-apps-cli": "2.0.10"
"@azure/static-web-apps-cli": "2.1.0"
},
"overrides": {
"adm-zip": "^0.6.1",