From fae4d5eb2f5d0b58892b70d586d85ef2442f25c3 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 7 May 2026 16:22:40 +0200 Subject: [PATCH 1/7] feat(backend): /api/auth/mcp returns isGlobalAdmin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP server needs to know the caller's GA status to make routing decisions (GA → /api/global/* with tenantId filter; non-GA → /api/* JWT-bound). Previously the access-check response only carried allowed/reason/accessGrant — the MCP layer had no way to detect role. McpUserService now resolves IsGlobalAdmin unconditionally (it's needed for both whitelist and AllMembers paths), and the result rides through on McpAccessCheckResult.IsGlobalAdmin. Existing factory signature stays backward-compatible via default-param. Prep for the upcoming MCP role-aware routing refactor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Infrastructure/McpUserFunction.cs | 3 ++- .../Services/McpUserService.cs | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs index 88f18d36..9f9af6f5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs @@ -171,7 +171,8 @@ public class McpUserFunction allowed = result.IsAllowed, upn = result.Upn, accessGrant = result.AccessGrant, - reason = result.Reason + reason = result.Reason, + isGlobalAdmin = result.IsGlobalAdmin }); return response; } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs index 2dc382be..dc83afe4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs @@ -46,6 +46,11 @@ public class McpUserService upn = upn.ToLowerInvariant(); + // Always resolve GA status — needed by the MCP server for cross-tenant routing + // decisions (GA → /api/global/* with tenantId-as-filter; non-GA → /api/* JWT-bound). + // Resolved unconditionally so downstream consumers don't need to re-check. + var isGlobalAdmin = await _globalAdminService.IsGlobalAdminAsync(upn); + var config = await _adminConfigService.GetConfigurationAsync(); if (!Enum.TryParse(config.McpAccessPolicy, out var policy)) policy = McpAccessPolicy.WhitelistOnly; @@ -56,13 +61,13 @@ public class McpUserService return McpAccessCheckResult.Denied("MCP access is disabled"); case McpAccessPolicy.AllMembers: - return McpAccessCheckResult.Allowed(upn, "AllMembers"); + return McpAccessCheckResult.Allowed(upn, "AllMembers", isGlobalAdmin); case McpAccessPolicy.WhitelistOnly: default: // Global Admins always have access - if (await _globalAdminService.IsGlobalAdminAsync(upn)) - return McpAccessCheckResult.Allowed(upn, "GlobalAdmin"); + if (isGlobalAdmin) + return McpAccessCheckResult.Allowed(upn, "GlobalAdmin", true); // Check McpUsers whitelist (cached) var cacheKey = $"mcp-user:{upn}"; @@ -73,7 +78,7 @@ public class McpUserService } return isMcpUser - ? McpAccessCheckResult.Allowed(upn, "McpUser") + ? McpAccessCheckResult.Allowed(upn, "McpUser", false) : McpAccessCheckResult.Denied("Not authorized for MCP access"); } } @@ -153,12 +158,14 @@ public class McpAccessCheckResult public string Upn { get; init; } = string.Empty; public string Reason { get; init; } = string.Empty; public string AccessGrant { get; init; } = string.Empty; + public bool IsGlobalAdmin { get; init; } - public static McpAccessCheckResult Allowed(string upn, string accessGrant) => new() + public static McpAccessCheckResult Allowed(string upn, string accessGrant, bool isGlobalAdmin = false) => new() { IsAllowed = true, Upn = upn, - AccessGrant = accessGrant + AccessGrant = accessGrant, + IsGlobalAdmin = isGlobalAdmin }; public static McpAccessCheckResult Denied(string reason) => new() From b9997809ed4b1606b0339ae33cb42084900028e0 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 7 May 2026 16:22:52 +0200 Subject: [PATCH 2/7] feat(backend): tenantId filter on global audit/logs + ops-events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both endpoints already supported optional date-window + pagination but ignored a tenantId query param. With the upcoming MCP role-aware routing GA needs /api/global/* endpoints to filter to a single tenant while keeping cross-tenant capability when the param is absent. - audit/logs: routes to existing tenant-scoped IMaintenanceRepository methods when tenantId is set (cheap PK lookup); fingerprint scope widened to "audit:global:tenant:" so cross-token reuse fails. - ops-events: OpsEvents are partitioned by category, not tenant — the filter is applied post-fetch. Pages may report fewer items than pageSize when the filter is narrow. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Admin/GetGlobalAuditLogsFunction.cs | 40 ++++++++++++++----- .../Functions/Admin/GetOpsEventsFunction.cs | 28 ++++++++++--- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs index c72eb8c9..2f5d9936 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs @@ -44,13 +44,28 @@ namespace AutopilotMonitor.Functions.Functions.Admin return bad; } + // Optional tenantId filter — when set, GA scopes the cross-tenant view to one + // tenant. Routing falls through to the tenant-scoped repository methods so + // the partition-key lookup stays cheap. Fingerprint scope is widened with the + // filter value so cross-token reuse is rejected. + var filterTenantId = query["tenantId"]; + var fingerprintScope = string.IsNullOrEmpty(filterTenantId) + ? "audit:global" + : $"audit:global:tenant:{filterTenantId}"; + var nextLinkExtras = string.IsNullOrEmpty(filterTenantId) + ? null + : new[] { new KeyValuePair("tenantId", filterTenantId) }; + _logger.LogInformation( - "Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}) for {User}", - parsed.DateFrom, parsed.DateTo, parsed.PageSize?.ToString() ?? "all", userEmail); + "Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, filterTenant={Filter}) for {User}", + parsed.DateFrom, parsed.DateTo, parsed.PageSize?.ToString() ?? "all", + filterTenantId ?? "(none)", userEmail); if (parsed.PageSize == null) { - var logs = await _maintenanceRepo.GetAllAuditLogsAsync(parsed.DateFrom, parsed.DateTo); + var logs = string.IsNullOrEmpty(filterTenantId) + ? await _maintenanceRepo.GetAllAuditLogsAsync(parsed.DateFrom, parsed.DateTo) + : await _maintenanceRepo.GetAuditLogsAsync(filterTenantId, parsed.DateFrom, parsed.DateTo); return await req.OkAsync(new { success = true, count = logs.Count, logs }); } @@ -59,11 +74,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin { if (!DateWindowPagination.TryAcceptContinuation( parsed.Continuation, - scope: "audit:global", + scope: fingerprintScope, callerTenantId: callerTenantId, dateFrom: parsed.DateFrom, dateTo: parsed.DateTo, - extras: null, + extras: nextLinkExtras, out azureToken, out var rejectReason)) { @@ -78,24 +93,29 @@ namespace AutopilotMonitor.Functions.Functions.Admin } } - var page = await _maintenanceRepo.GetAllAuditLogsPageAsync( - parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken); + var page = string.IsNullOrEmpty(filterTenantId) + ? await _maintenanceRepo.GetAllAuditLogsPageAsync( + parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken) + : await _maintenanceRepo.GetAuditLogsPageAsync( + filterTenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken); string? nextLink = null; if (!string.IsNullOrEmpty(page.NextRawToken)) { var fp = DateWindowPagination.Fingerprint( - scope: "audit:global", + scope: fingerprintScope, callerTenantId: callerTenantId, dateFrom: parsed.DateFrom, - dateTo: parsed.DateTo); + dateTo: parsed.DateTo, + extras: nextLinkExtras); var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp); nextLink = DateWindowPagination.BuildNextLink( basePath: "/api/global/audit/logs", pageSize: parsed.PageSize.Value, wireContinuation: wireToken, dateFrom: parsed.DateFrom, - dateTo: parsed.DateTo); + dateTo: parsed.DateTo, + extras: nextLinkExtras); } return await req.OkAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs index 5bafe1d4..83feae2d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs @@ -38,6 +38,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin var callerTenantId = TenantHelper.GetTenantId(req); var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); var category = query["category"]; + var filterTenantId = query["tenantId"]; var parsed = DateWindowPagination.ParseQuery(query); if (parsed.Error != null) @@ -47,14 +48,27 @@ namespace AutopilotMonitor.Functions.Functions.Admin return bad; } - var extras = string.IsNullOrEmpty(category) - ? null - : new[] { new KeyValuePair("category", category) }; + // OpsEvents are partitioned by category, not tenant — tenantId filter is applied + // post-fetch. Pages may report fewer items than pageSize when the filter is + // narrow (Azure scan returns pageSize raw rows; we filter and emit what passes). + var extrasList = new List>(); + if (!string.IsNullOrEmpty(category)) + extrasList.Add(new KeyValuePair("category", category)); + if (!string.IsNullOrEmpty(filterTenantId)) + extrasList.Add(new KeyValuePair("tenantId", filterTenantId)); + var extras = extrasList.Count > 0 ? extrasList.ToArray() : null; + + static IEnumerable ApplyTenantFilter( + IEnumerable source, string? tenantFilter) => + string.IsNullOrEmpty(tenantFilter) + ? source + : source.Where(e => string.Equals(e.TenantId, tenantFilter, StringComparison.OrdinalIgnoreCase)); if (parsed.PageSize == null) { var events = await _repository.GetOpsEventsAsync(category, parsed.DateFrom, parsed.DateTo); - return await req.OkAsync(new { success = true, count = events.Count, events }); + var filtered = ApplyTenantFilter(events, filterTenantId).ToList(); + return await req.OkAsync(new { success = true, count = filtered.Count, events = filtered }); } string? azureToken = null; @@ -84,6 +98,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin var page = await _repository.GetOpsEventsPageAsync( category, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken); + var pageItems = ApplyTenantFilter(page.Items, filterTenantId).ToList(); + string? nextLink = null; if (!string.IsNullOrEmpty(page.NextRawToken)) { @@ -106,8 +122,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin return await req.OkAsync(new { success = true, - count = page.Items.Count, - events = page.Items, + count = pageItems.Count, + events = pageItems, nextLink, }); } From 84a1aa14cbebc4214867a567374c794ba88a0c89 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 7 May 2026 16:27:49 +0200 Subject: [PATCH 3/7] feat(backend): server-side filters for /api/sessions/{id}/events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push eventType/severity/source filters from MCP-tool layer down into the Function so count and nextLink stay coherent. Previously filters lived client-side: backend returned the full page, MCP filtered → could produce count: 0 with nextLink set, which read as a bug ("nextLink is emitted even though there's nothing left"). With server-side filters: - count reflects post-filter items in this page - nextLink is still gated on NextRawToken (more raw rows ahead in this session — filter may or may not match further down) - filter values echo back onto nextLink so callers can follow pagination without having to re-supply them SessionEventsPagination.BuildNextLink learns an optional `extras` arg so arbitrary query params survive the round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sessions/GetSessionEventsFunction.cs | 68 +++++++++++++++++-- .../Pagination/SessionEventsPagination.cs | 12 +++- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs index da69eaa0..bf640cc1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs @@ -3,6 +3,7 @@ using System.Web; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Pagination; using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Pagination; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -56,6 +57,43 @@ namespace AutopilotMonitor.Functions.Functions.Sessions var requestCtx = req.GetRequestContext(); var tenantIdQueryParam = query["tenantId"]; + // Optional server-side filters (post-fetch). Previously these lived in the MCP + // tool layer, which made count and nextLink semantics inconsistent (filter → + // count: 0 + nextLink "more ahead" was confusing). Centralizing here keeps + // count and nextLink coherent for every consumer. + var filterEventType = query["eventType"]; + var filterSeverity = query["severity"]; + var filterSource = query["source"]; + var hasFilters = !string.IsNullOrEmpty(filterEventType) + || !string.IsNullOrEmpty(filterSeverity) + || !string.IsNullOrEmpty(filterSource); + + static IEnumerable ApplyFilters( + IEnumerable source, + string? eventType, string? severity, string? sourceFilter) + { + EventSeverity? wantSeverity = null; + if (!string.IsNullOrEmpty(severity) + && Enum.TryParse(severity, ignoreCase: true, out var parsed)) + { + wantSeverity = parsed; + } + + foreach (var e in source) + { + if (!string.IsNullOrEmpty(eventType) && + !string.Equals(e.EventType, eventType, StringComparison.OrdinalIgnoreCase)) + continue; + if (wantSeverity.HasValue && e.Severity != wantSeverity.Value) + continue; + if (!string.IsNullOrEmpty(sourceFilter) && + (e.Source == null || + e.Source.IndexOf(sourceFilter, StringComparison.OrdinalIgnoreCase) < 0)) + continue; + yield return e; + } + } + if (pagination.PageSize == null) { // Legacy unpaginated path — full list, no nextLink. @@ -70,12 +108,16 @@ namespace AutopilotMonitor.Functions.Functions.Sessions } } + var filtered = hasFilters + ? ApplyFilters(events, filterEventType, filterSeverity, filterSource).ToList() + : events; + return await req.OkAsync(new { success = true, sessionId, - count = events.Count, - events, + count = filtered.Count, + events = filtered, }); } @@ -137,21 +179,37 @@ namespace AutopilotMonitor.Functions.Functions.Sessions var page = await _sessionRepo.GetSessionEventsPageAsync( effectiveTenantId, sessionId, pagination.PageSize.Value, azureToken); + var pageItems = hasFilters + ? ApplyFilters(page.Items, filterEventType, filterSeverity, filterSource).ToList() + : page.Items; + string? nextLink = null; if (!string.IsNullOrEmpty(page.NextRawToken)) { + // Filters are echoed onto the nextLink so the client doesn't have to + // re-supply them when continuing pagination. Both BuildNextLink and the + // client (MCP, UI) round-trip these via the continuation URL verbatim. + var extras = new List>(3); + if (!string.IsNullOrEmpty(filterEventType)) + extras.Add(new KeyValuePair("eventType", filterEventType)); + if (!string.IsNullOrEmpty(filterSeverity)) + extras.Add(new KeyValuePair("severity", filterSeverity)); + if (!string.IsNullOrEmpty(filterSource)) + extras.Add(new KeyValuePair("source", filterSource)); + var fp = SessionEventsPagination.Fingerprint(effectiveTenantId, sessionId); var wireToken = ContinuationToken.Encode(page.NextRawToken!, effectiveTenantId, fp); nextLink = SessionEventsPagination.BuildNextLink( - sessionId, pagination.PageSize.Value, wireToken, effectiveTenantId); + sessionId, pagination.PageSize.Value, wireToken, effectiveTenantId, + extras: extras.Count > 0 ? extras : null); } return await req.OkAsync(new { success = true, sessionId, - count = page.Items.Count, - events = page.Items, + count = pageItems.Count, + events = pageItems, nextLink, }); } diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs index 7e46ad3f..92a8a3d6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs @@ -100,7 +100,8 @@ namespace AutopilotMonitor.Functions.Pagination string sessionId, int pageSize, string wireContinuation, - string? tenantId) + string? tenantId, + IEnumerable>? extras = null) { var sb = new StringBuilder(); sb.Append("/api/sessions/").Append(sessionId).Append("/events"); @@ -110,6 +111,15 @@ namespace AutopilotMonitor.Functions.Pagination { sb.Append("&tenantId=").Append(System.Uri.EscapeDataString(tenantId)); } + if (extras != null) + { + foreach (var kv in extras) + { + if (string.IsNullOrEmpty(kv.Key) || string.IsNullOrEmpty(kv.Value)) continue; + sb.Append('&').Append(System.Uri.EscapeDataString(kv.Key)) + .Append('=').Append(System.Uri.EscapeDataString(kv.Value!)); + } + } return sb.ToString(); } } From 3116a0ecc9635b302d1f81f66df536f99d1aad13 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 7 May 2026 16:31:39 +0200 Subject: [PATCH 4/7] feat(backend,web): geographic-sessions default-lean (?full=1 opt-out) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full LocationSessionRow shape is ~40 fields per session — for locations with dozens of sessions the response easily blew past 100KB and tripped MCP token caps. Most fields aren't needed for the typical "which sessions enrolled from here" triage flow. Default is now a 17-field lean projection (sessionId, device basics, status/duration, geo, app/DO summary). UI keeps the full shape via ?full=1 — single-line addition in lib/api.ts. Also adds an optional tenantId filter on /api/global/metrics/geographic/ sessions so a GA can scope a single tenant's sessions for a location without leaving the global endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../GetGeographicLocationSessionsFunction.cs | 46 +++++++++++++++++-- ...lobalGeographicLocationSessionsFunction.cs | 26 +++++++++-- src/Web/autopilot-monitor-web/lib/api.ts | 7 ++- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs index 7e071625..e68c0685 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs @@ -50,9 +50,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics if (days > 365) days = 365; var groupBy = query["groupBy"] ?? "city"; + // Default response shape is lean (~15 fields/row) so MCP-driven callers fit + // many sessions in a single response. UI sets ?full=1 to opt back into the + // full LocationSessionRow shape. + var full = string.Equals(query["full"], "1", StringComparison.Ordinal); - _logger.LogInformation("Fetching sessions for location '{LocationKey}' tenant {TenantId} ({Days}d, groupBy={GroupBy})", - locationKey, tenantId, days, groupBy); + _logger.LogInformation("Fetching sessions for location '{LocationKey}' tenant {TenantId} ({Days}d, groupBy={GroupBy}, full={Full})", + locationKey, tenantId, days, groupBy, full); var cutoff = DateTime.UtcNow.AddDays(-days); var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId); @@ -62,7 +66,15 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var rows = BuildRows(filtered, appSummaries); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { success = true, sessions = rows, totalCount = rows.Count }); + if (full) + { + await response.WriteAsJsonAsync(new { success = true, sessions = rows, totalCount = rows.Count }); + } + else + { + var lean = rows.Select(ToLeanRow).ToList(); + await response.WriteAsJsonAsync(new { success = true, sessions = lean, totalCount = lean.Count }); + } return response; } catch (Exception ex) @@ -99,5 +111,33 @@ namespace AutopilotMonitor.Functions.Functions.Metrics } return rows; } + + /// + /// Lean projection used as the default response shape. Fields chosen to support + /// the typical MCP triage flow (which session, when, where, how big a deal, was + /// DO active) while keeping per-row payload an order of magnitude smaller than + /// the full . Callers needing the full shape + /// pass ?full=1. + /// + internal static object ToLeanRow(LocationSessionRow r) => new + { + sessionId = r.SessionId, + tenantId = r.TenantId, + serialNumber = r.SerialNumber, + deviceName = r.DeviceName, + manufacturer = r.Manufacturer, + model = r.Model, + startedAt = r.StartedAt, + completedAt = r.CompletedAt, + status = r.Status, + failureReason = r.FailureReason, + durationSeconds = r.DurationSeconds, + enrollmentType = r.EnrollmentType, + geoCountry = r.GeoCountry, + geoCity = r.GeoCity, + totalAppCount = r.TotalAppCount, + hasDoTelemetry = r.HasDoTelemetry, + doPercentPeerCaching = r.DoPercentPeerCaching, + }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs index 74c1cbcb..b447bfcd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs @@ -47,19 +47,35 @@ namespace AutopilotMonitor.Functions.Functions.Metrics days = parsedDays; var groupBy = query["groupBy"] ?? "city"; + var full = string.Equals(query["full"], "1", StringComparison.Ordinal); + // Optional tenantId filter so a GA can scope a single tenant's sessions + // for a location without leaving the cross-tenant endpoint. + var filterTenantId = query["tenantId"]; - _logger.LogInformation("Fetching global sessions for location '{LocationKey}' ({Days}d, groupBy={GroupBy}) (User: {UserEmail})", - locationKey, days, groupBy, userEmail); + _logger.LogInformation("Fetching global sessions for location '{LocationKey}' ({Days}d, groupBy={GroupBy}, full={Full}, tenantFilter={Tenant}) (User: {UserEmail})", + locationKey, days, groupBy, full, filterTenantId ?? "(none)", userEmail); var cutoff = DateTime.UtcNow.AddDays(-days); - var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)); + var sessions = string.IsNullOrEmpty(filterTenantId) + ? await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)) + : await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), filterTenantId); var filtered = GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, locationKey, groupBy); - var appSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync(); + var appSummaries = string.IsNullOrEmpty(filterTenantId) + ? await _metricsRepo.GetAllAppInstallSummariesAsync() + : await _metricsRepo.GetAppInstallSummariesByTenantAsync(filterTenantId); var rows = GetGeographicLocationSessionsFunction.BuildRows(filtered, appSummaries); var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { success = true, sessions = rows, totalCount = rows.Count }); + if (full) + { + await response.WriteAsJsonAsync(new { success = true, sessions = rows, totalCount = rows.Count }); + } + else + { + var lean = rows.Select(GetGeographicLocationSessionsFunction.ToLeanRow).ToList(); + await response.WriteAsJsonAsync(new { success = true, sessions = lean, totalCount = lean.Count }); + } return response; } catch (Exception ex) diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index dcbca23d..cfb90665 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -176,10 +176,13 @@ export const api = { `${API_BASE_URL}/api/metrics/geographic${qs({ tenantId, days: String(days), groupBy })}`, globalGeographic: (days: number, groupBy: string, tenantId?: string) => `${API_BASE_URL}/api/global/metrics/geographic${qs({ days: String(days), groupBy, tenantId })}`, + // ?full=1 — UI needs the full LocationSessionRow shape for the page table; default + // is now lean (~15 fields/row) so MCP-driven callers fit many sessions in a single + // response. geographicSessions: (tenantId: string, days: number, groupBy: string, locationKey: string) => - `${API_BASE_URL}/api/metrics/geographic/sessions${qs({ tenantId, days: String(days), groupBy, locationKey })}`, + `${API_BASE_URL}/api/metrics/geographic/sessions${qs({ tenantId, days: String(days), groupBy, locationKey, full: '1' })}`, globalGeographicSessions: (days: number, groupBy: string, locationKey: string) => - `${API_BASE_URL}/api/global/metrics/geographic/sessions${qs({ days: String(days), groupBy, locationKey })}`, + `${API_BASE_URL}/api/global/metrics/geographic/sessions${qs({ days: String(days), groupBy, locationKey, full: '1' })}`, platform: (opts?: { limit?: number; days?: number }) => `${API_BASE_URL}/api/global/metrics/platform${qs({ limit: opts?.limit?.toString(), From 629c12eb55b63cb016160302e7e87a989c3b3751 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 7 May 2026 16:37:27 +0200 Subject: [PATCH 5/7] =?UTF-8?q?feat(mcp):=20role-aware=20routing=20?= =?UTF-8?q?=E2=80=94=20GA=20=E2=86=92=20/api/global/*,=20tenant-admin=20?= =?UTF-8?q?=E2=86=92=20/api/*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the silent confusion where a Global Admin who passed `tenantId=X` got routed to the tenant-scoped endpoint, which used the GA's own JWT tenant (home), not the requested one. The user saw their home tenant's data instead of X — no error, just wrong data. Caller role flows from /api/auth/mcp (now returns isGlobalAdmin) into an AsyncLocalStorage caller context (token + isGlobalAdmin). Tools pick the route via a small helper: pickGlobalOrTenantPath('/api/global/X', '/api/X') GA → /api/global/X (cross-tenant; tenantId is a filter param) Tenant-Admin → /api/X (tenant-scoped; JWT-tid authoritative, isolation strict) Refactors 13 routing sites across tools/{sessions,admin,search}.ts. Each backend pair was verified to exist (both `/api/X` and `/api/global/X` have a Function defined). Also drops the MCP-side filter pass in get_session_events — server-side filtering was added in the previous commit so count and nextLink are now coherent. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../autopilot-monitor-mcp/src/access-guard.ts | 34 +++++++++---- .../autopilot-monitor-mcp/src/client.ts | 49 +++++++++++++++---- .../autopilot-monitor-mcp/src/tools/admin.ts | 12 ++--- .../autopilot-monitor-mcp/src/tools/search.ts | 6 +-- .../src/tools/sessions.ts | 40 ++++++--------- 5 files changed, 88 insertions(+), 53 deletions(-) diff --git a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts index 2b48dd04..f98034be 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts @@ -9,7 +9,7 @@ */ import type { Request, Response, NextFunction } from 'express'; import { extractTokenClaims, isTokenExpired } from './auth.js'; -import { runWithToken } from './client.js'; +import { runWithCaller } from './client.js'; const BASE_URL = process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.azurewebsites.net'; @@ -30,15 +30,22 @@ const ACCESS_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes interface AccessCacheEntry { allowed: boolean; reason: string; + isGlobalAdmin: boolean; expiresAt: number; } const accessCache = new Map(); -async function checkAccess(upn: string, token: string): Promise<{ allowed: boolean; reason: string }> { +interface AccessCheckResult { + allowed: boolean; + reason: string; + isGlobalAdmin: boolean; +} + +async function checkAccess(upn: string, token: string): Promise { const cached = accessCache.get(upn); if (cached && Date.now() < cached.expiresAt) { - return { allowed: cached.allowed, reason: cached.reason }; + return { allowed: cached.allowed, reason: cached.reason, isGlobalAdmin: cached.isGlobalAdmin }; } try { @@ -49,13 +56,19 @@ async function checkAccess(upn: string, token: string): Promise<{ allowed: boole const text = await res.text(); if (!text) { console.error(`[access-guard] Backend returned empty body for ${upn} (status=${res.status})`); - return { allowed: false, reason: `Backend returned ${res.status} with empty body` }; + return { allowed: false, reason: `Backend returned ${res.status} with empty body`, isGlobalAdmin: false }; } - const data = JSON.parse(text) as { allowed: boolean; reason?: string; accessGrant?: string }; - const result = { + const data = JSON.parse(text) as { + allowed: boolean; + reason?: string; + accessGrant?: string; + isGlobalAdmin?: boolean; + }; + const result: AccessCheckResult = { allowed: data.allowed === true, reason: data.allowed ? (data.accessGrant ?? 'allowed') : (data.reason ?? 'denied'), + isGlobalAdmin: data.isGlobalAdmin === true, }; accessCache.set(upn, { ...result, expiresAt: Date.now() + ACCESS_CACHE_TTL_MS }); @@ -63,7 +76,7 @@ async function checkAccess(upn: string, token: string): Promise<{ allowed: boole } catch (err) { console.error(`[access-guard] Backend check failed for ${upn}:`, err); // Fail-closed: deny on backend error - return { allowed: false, reason: 'Backend access check unavailable' }; + return { allowed: false, reason: 'Backend access check unavailable', isGlobalAdmin: false }; } } @@ -148,9 +161,10 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo return; } - // Scope the token to this async context so concurrent sessions - // cannot overwrite each other's tokens on the event loop. - runWithToken(token, () => next()); + // Scope the caller context (token + GA status) to this async context + // so concurrent sessions cannot overwrite each other on the event loop, + // and so tools can route based on role without re-checking the JWT. + runWithCaller({ token, isGlobalAdmin: result.isGlobalAdmin }, () => next()); }) .catch(() => { res.status(503).json({ error: 'Access check failed' }); diff --git a/src/McpServer/autopilot-monitor-mcp/src/client.ts b/src/McpServer/autopilot-monitor-mcp/src/client.ts index ca83cd25..1b68ce31 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/client.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/client.ts @@ -29,25 +29,54 @@ export class ApiError extends Error { } /** - * Per-request token store using AsyncLocalStorage. + * Per-request caller context using AsyncLocalStorage. * * Each incoming MCP request runs inside its own async context (via - * `runWithToken`), so concurrent sessions cannot overwrite each other's - * tokens — even when async operations interleave on the event loop. + * `runWithCaller`), carrying both the Bearer token and the caller's + * resolved Global-Admin status. Tools route based on the role: + * - GA → /api/global/* (cross-tenant; tenantId is a filter param) + * - Tenant-Admin → /api/* (tenant-scoped; JWT-tid is authoritative) + * + * Concurrent sessions cannot overwrite each other's context even when + * async operations interleave on the event loop. */ -const tokenStore = new AsyncLocalStorage(); +interface CallerContext { + token: string; + isGlobalAdmin: boolean; +} + +const callerStore = new AsyncLocalStorage(); /** - * Run a callback within an async context that carries the given Bearer token. - * All calls to `apiFetch` inside the callback (and its async descendants) - * will automatically use this token. + * Run a callback within an async context that carries the given caller info. + * All calls to `apiFetch` and routing helpers inside the callback (and its + * async descendants) will automatically see this context. */ -export function runWithToken(token: string, fn: () => T): T { - return tokenStore.run(token, fn); +export function runWithCaller(ctx: CallerContext, fn: () => T): T { + return callerStore.run(ctx, fn); } export function getCurrentToken(): string | undefined { - return tokenStore.getStore(); + return callerStore.getStore()?.token; +} + +/** + * Returns true if the current request is being made by a Global Admin. + * Defaults to false when no context is active (e.g. unit tests without + * an explicit `runWithCaller`). + */ +export function isGlobalAdmin(): boolean { + return callerStore.getStore()?.isGlobalAdmin === true; +} + +/** + * Picks the route prefix for the current caller. GA always uses + * `/api/global/*`; tenant-admins use the tenant-scoped variant. The same + * `tenantId` query param is meaningful in both worlds — on `/api/global/*` + * it filters; on `/api/*` it's informational (backend resolves from JWT). + */ +export function pickGlobalOrTenantPath(globalPath: string, tenantPath: string): string { + return isGlobalAdmin() ? globalPath : tenantPath; } /** diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index e1abad49..4c56aec8 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { apiFetch, buildQuery, followNextLink } from '../client.js'; +import { apiFetch, buildQuery, followNextLink, pickGlobalOrTenantPath } from '../client.js'; import { withToolTelemetry } from '../telemetry.js'; import { READ_ONLY, READ_ONLY_OPEN, MAX_RESULT_SIZE_CHARS, toolResultText } from './shared.js'; import { toolError } from './error-handler.js'; @@ -58,7 +58,7 @@ export function registerAdminTools(server: McpServer): void { const { tenantId, ...rest } = args; const params: Record = { ...rest }; if (tenantId) params.tenantId = tenantId; - const prefix = tenantId ? '/api/metrics' : '/api/global/metrics'; + const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics'); const data = await apiFetch(`${prefix}/geographic${buildQuery(params)}`); return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); } catch (error: unknown) { @@ -93,7 +93,7 @@ export function registerAdminTools(server: McpServer): void { params.locationKey = parts.join(', '); params.groupBy = city ? 'city' : region ? 'region' : 'country'; } - const prefix = tenantId ? '/api/metrics' : '/api/global/metrics'; + const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics'); const data = await apiFetch(`${prefix}/geographic/sessions${buildQuery(params)}`); return toolResultText(data, MAX_RESULT_SIZE_CHARS.sessions); } catch (error: unknown) { @@ -232,7 +232,7 @@ export function registerAdminTools(server: McpServer): void { async (args) => withToolTelemetry('get_audit_logs', async () => { try { const { tenantId, dateFrom, dateTo, pageSize, continuation } = args; - const basePath = tenantId ? '/api/audit/logs' : '/api/global/audit/logs'; + const basePath = pickGlobalOrTenantPath('/api/global/audit/logs', '/api/audit/logs'); const path = followNextLink( basePath, { tenantId, dateFrom, dateTo, pageSize }, @@ -347,7 +347,7 @@ export function registerAdminTools(server: McpServer): void { async (args) => withToolTelemetry('query_raw_events', async () => { try { const { tenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, pageSize, continuation } = args; - const basePath = tenantId ? '/api/raw/events' : '/api/global/raw/events'; + const basePath = pickGlobalOrTenantPath('/api/global/raw/events', '/api/raw/events'); const path = followNextLink( basePath, { tenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, pageSize }, @@ -394,7 +394,7 @@ export function registerAdminTools(server: McpServer): void { try { const { tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix, imeAgentVersion, imeAgentVersionPrefix, fields, pageSize, continuation } = args; - const basePath = tenantId ? '/api/raw/sessions' : '/api/global/raw/sessions'; + const basePath = pickGlobalOrTenantPath('/api/global/raw/sessions', '/api/raw/sessions'); const path = followNextLink( basePath, { tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix, diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts index ce261652..5dc61e2b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { apiFetch, buildQuery } from '../client.js'; +import { apiFetch, buildQuery, pickGlobalOrTenantPath } from '../client.js'; import { withToolTelemetry } from '../telemetry.js'; import type { SearchProvider } from '../search-provider.js'; import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText } from './shared.js'; @@ -63,7 +63,7 @@ async function fetchSessionEvents( sessionLimit: 10, }; if (tenantId) params.tenantId = tenantId; - const basePath = tenantId ? '/api/raw/events/search' : '/api/global/raw/events/search'; + const basePath = pickGlobalOrTenantPath('/api/global/raw/events/search', '/api/raw/events/search'); const data = await apiFetch(`${basePath}${buildQuery(params)}`) as { events?: EventEntry[]; }; @@ -83,7 +83,7 @@ async function fetchSessionEvents( const searchParams: Record = { status: 'Failed', limit: 5 }; if (tenantId) searchParams.tenantId = tenantId; const searchQ = buildQuery(searchParams); - const searchBase = tenantId ? '/api/search/sessions' : '/api/global/search/sessions'; + const searchBase = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions'); const sessions = await apiFetch(`${searchBase}${searchQ}`) as { sessions?: Array<{ sessionId?: string }>; }; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index d3f799f8..1bae928b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import { apiFetch, buildQuery, followNextLink } from '../client.js'; +import { apiFetch, buildQuery, followNextLink, pickGlobalOrTenantPath } from '../client.js'; import { withToolTelemetry } from '../telemetry.js'; import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText } from './shared.js'; import { toolError } from './error-handler.js'; @@ -91,7 +91,8 @@ export function registerSessionTools(server: McpServer): void { async (args) => withToolTelemetry('search_sessions', async () => { try { const { deviceProperties, tenantId, pageSize, continuation, ...rest } = args; - const basePath = tenantId ? '/api/search/sessions' : '/api/global/search/sessions'; + // GA → /api/global/search/sessions (tenantId is filter); Tenant-Admin → /api/search/sessions (JWT-bound). + const basePath = pickGlobalOrTenantPath('/api/global/search/sessions', '/api/search/sessions'); // followNextLink handles full nextLink paths verbatim. For first-page calls // we still need to layer in deviceProperties as `prop.` query params, // which followNextLink doesn't know about — so build the param record and @@ -133,7 +134,7 @@ export function registerSessionTools(server: McpServer): void { async (args) => withToolTelemetry('search_sessions_by_event', async () => { try { const { eventType, tenantId, pageSize, continuation } = args; - const basePath = tenantId ? '/api/search/sessions-by-event' : '/api/global/search/sessions-by-event'; + const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-event', '/api/search/sessions-by-event'); const path = followNextLink( basePath, { eventType, tenantId, pageSize }, @@ -204,26 +205,16 @@ export function registerSessionTools(server: McpServer): void { READ_ONLY, async (args) => withToolTelemetry('get_session_events', async () => { try { - const { sessionId, tenantId, pageSize, continuation, ...filters } = args; + const { sessionId, tenantId, pageSize, continuation, eventType, severity, source } = args; + // Filters live server-side now — count and nextLink stay coherent (a non-zero + // nextLink with count: 0 means "filter didn't match in this page; more raw rows + // ahead — follow nextLink to keep scanning"). const path = followNextLink( `/api/sessions/${sessionId}/events`, - { tenantId, pageSize }, + { tenantId, pageSize, eventType, severity, source }, continuation, ); - const data = await apiFetch(path) as { - events?: Array<{ eventType?: string; severity?: string; source?: string }>; - count?: number; - nextLink?: string; - }; - if (data?.events && (filters.eventType || filters.severity || filters.source)) { - data.events = data.events.filter((e) => { - if (filters.eventType && e.eventType !== filters.eventType) return false; - if (filters.severity && e.severity !== filters.severity) return false; - if (filters.source && !e.source?.toLowerCase().includes(filters.source.toLowerCase())) return false; - return true; - }); - data.count = data.events.length; - } + const data = await apiFetch(path); return toolResultText(data, MAX_RESULT_SIZE_CHARS.events); } catch (error: unknown) { return toolError('get_session_events', args, error); @@ -383,7 +374,7 @@ export function registerSessionTools(server: McpServer): void { const params: Record = { ...rest }; if (tenantId) params.tenantId = tenantId; const q = buildQuery(params); - const prefix = tenantId ? '/api/metrics' : '/api/global/metrics'; + const prefix = pickGlobalOrTenantPath('/api/global/metrics', '/api/metrics'); const [summary, apps] = await Promise.all([ apiFetch(`${prefix}/summary${q}`).catch(() => null), apiFetch(`${prefix}/app${q}`).catch(() => null), @@ -419,7 +410,7 @@ export function registerSessionTools(server: McpServer): void { async (args) => withToolTelemetry('search_sessions_by_cve', async () => { try { const { cveId, tenantId, minCvssScore, overallRisk, pageSize, continuation } = args; - const basePath = tenantId ? '/api/search/sessions-by-cve' : '/api/global/search/sessions-by-cve'; + const basePath = pickGlobalOrTenantPath('/api/global/search/sessions-by-cve', '/api/search/sessions-by-cve'); const path = followNextLink( basePath, { cveId, tenantId, minCvssScore, overallRisk, pageSize }, @@ -446,9 +437,10 @@ export function registerSessionTools(server: McpServer): void { async (args) => withToolTelemetry('list_blocked_devices', async () => { try { const { tenantId } = args; - const endpoint = tenantId - ? `/api/devices/blocked${buildQuery({ tenantId } as Record)}` - : '/api/global/devices/blocked'; + // GA: /api/global/devices/blocked (tenantId is filter); non-GA: /api/devices/blocked + // (backend will 403 — list is platform-wide and GA-only by policy). + const basePath = pickGlobalOrTenantPath('/api/global/devices/blocked', '/api/devices/blocked'); + const endpoint = `${basePath}${buildQuery({ tenantId } as Record)}`; const data = await apiFetch(endpoint); return toolResultText(data, MAX_RESULT_SIZE_CHARS.adminStream); } catch (error: unknown) { From b4529d2c716effd12190841807dc02c3ee6b938d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 7 May 2026 16:40:01 +0200 Subject: [PATCH 6/7] feat(mcp): get_resource tool + description tightening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workaround for clients (e.g. Claude Code's HTTP-MCP bridge in stateless mode) that don't expose `resources/list` reliably. Verified with a paired in-memory client that the server itself advertises `capabilities.resources` and answers `resources/list` correctly — the breakage is on the client side. Tools work everywhere, so a tool-call gateway is the pragmatic fix. Resources now share a single source-of-truth (`resource-catalog.ts`) used by both the MCP-protocol resource handlers and the new `get_resource(name)` tool. Tool descriptions previously said "see the device_properties resource" — updated to "call get_resource(name=...)" so the hint actually leads somewhere usable. Also tightens get_rule_stats description: the default window × number of rules × per-day trend can easily exceed the response cap, so the doc now recommends passing a tighter date range or ruleType filter for routine queries. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/resource-catalog.ts | 117 ++++++++++++++++++ .../autopilot-monitor-mcp/src/resources.ts | 115 ++--------------- .../autopilot-monitor-mcp/src/tools/admin.ts | 30 ++++- .../src/tools/sessions.ts | 8 +- 4 files changed, 159 insertions(+), 111 deletions(-) create mode 100644 src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts new file mode 100644 index 00000000..994ab0be --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -0,0 +1,117 @@ +/** + * Source-of-truth for the static resource catalogs (event types, device + * properties). Consumed by both `resources.ts` (MCP-protocol resources) + * and the `get_resource` tool (workaround for stateless-HTTP MCP clients + * whose resource discovery is broken). + */ + +export const EVENT_TYPES_CATALOG = { + phase_events: [ + 'phase_transition', + 'esp_state_change', + 'completion_check', + 'enrollment_complete', + 'enrollment_failed', + 'desktop_arrived', + ], + app_events: [ + 'app_install_started', + 'app_install_completed', + 'app_install_failed', + 'app_download_started', + 'app_install_skipped', + ], + network_events: ['network_state_change', 'network_connectivity_check'], + device_info_events: [ + 'os_info', + 'hardware_spec', + 'tpm_status', + 'autopilot_profile', + 'secureboot_status', + 'bitlocker_status', + 'network_adapters', + 'network_interface_info', + 'aad_join_status', + 'enrollment_type_detected', + ], + error_events: ['error_detected'], + vulnerability_events: ['software_inventory_analysis', 'vulnerability_report'], + other: [ + 'performance_snapshot', + 'log_entry', + 'gather_result', + 'script_completed', + 'script_failed', + 'ime_agent_version', + ], +} as const; + +export const DEVICE_PROPERTIES_CATALOG = { + _usage: { + note: 'Use these keys in the deviceProperties parameter of search_sessions.', + booleanFormat: 'Use "True" or "False" (case-insensitive)', + numericOperators: 'Prefix value with >=, <=, >, or < for range filters (e.g. ">=8")', + arraySearch: 'For array properties, value is matched as substring in any array element', + }, + tpm_status: { + 'tpm_status.specVersion': { type: 'string', description: 'TPM specification version (e.g. "2.0", "1.2")' }, + 'tpm_status.manufacturerName': { type: 'string', description: 'TPM manufacturer (e.g. "INTC", "IFX")' }, + 'tpm_status.manufacturerVersion': { type: 'string', description: 'TPM firmware version' }, + 'tpm_status.isActivated': { type: 'boolean', description: 'TPM activation status' }, + 'tpm_status.isEnabled': { type: 'boolean', description: 'TPM enabled status' }, + 'tpm_status.isOwned': { type: 'boolean', description: 'TPM ownership status' }, + }, + secureboot_status: { + 'secureboot_status.uefiSecureBootEnabled': { type: 'boolean', description: 'Secure Boot enabled' }, + }, + bitlocker_status: { + 'bitlocker_status.systemDriveProtected': { type: 'boolean', description: 'System drive (C:) BitLocker protection' }, + 'bitlocker_status.volumes': { type: 'array', description: 'All BitLocker volumes — search by volume letter or protection status' }, + }, + autopilot_profile: { + 'autopilot_profile.autopilotModeLabel': { type: 'string', description: 'Deployment mode (e.g. "User Driven (0)", "Self-Deploying (1)")' }, + 'autopilot_profile.domainJoinMethodLabel': { type: 'string', description: 'Join method (e.g. "Entra Join", "Hybrid Azure AD Join")' }, + 'autopilot_profile.CloudAssignedEspEnabled': { type: 'boolean', description: 'ESP enabled in Autopilot profile' }, + 'autopilot_profile.CloudAssignedDeviceName': { type: 'string', description: 'Cloud-assigned device name template' }, + 'autopilot_profile.CloudAssignedLanguage': { type: 'string', description: 'Cloud-assigned language/locale' }, + }, + hardware_spec: { + 'hardware_spec.cpuName': { type: 'string', description: 'CPU model name (e.g. "Intel Core i7-1265U")' }, + 'hardware_spec.cpuCores': { type: 'number', description: 'Number of physical CPU cores' }, + 'hardware_spec.cpuLogicalProcessors': { type: 'number', description: 'Number of logical processors' }, + 'hardware_spec.ramTotalGB': { type: 'number', description: 'Total RAM in GB. Use >=N for minimum (e.g. ">=8")' }, + 'hardware_spec.ramSpeed': { type: 'string', description: 'RAM speed (e.g. "3200 MHz")' }, + 'hardware_spec.ramType': { type: 'string', description: 'RAM type (e.g. "DDR4", "LPDDR5")' }, + 'hardware_spec.diskCount': { type: 'number', description: 'Number of physical disks' }, + 'hardware_spec.hasSSD': { type: 'boolean', description: 'Has SSD or NVMe storage (computed)' }, + 'hardware_spec.disks': { type: 'array', description: 'Disk details — search for "NVMe", "SSD", disk model names' }, + 'hardware_spec.systemDriveFreeGB': { type: 'number', description: 'Free space on C: in GB' }, + 'hardware_spec.systemDriveTotalGB': { type: 'number', description: 'Total C: drive size in GB' }, + 'hardware_spec.biosVersion': { type: 'string', description: 'BIOS/UEFI version string' }, + 'hardware_spec.batteryPresent': { type: 'boolean', description: 'Device has a battery' }, + 'hardware_spec.batteryHealthPercent': { type: 'number', description: 'Battery health percentage' }, + 'hardware_spec.gpus': { type: 'array', description: 'GPU details — search for GPU model names' }, + }, + network_interface_info: { + 'network_interface_info.connectionType': { type: 'string', description: 'Connection type: "WiFi" or "Ethernet"' }, + 'network_interface_info.linkSpeedMbps': { type: 'number', description: 'Link speed in Mbps' }, + 'network_interface_info.adapterName': { type: 'string', description: 'Network adapter name' }, + 'network_interface_info.macAddress': { type: 'string', description: 'MAC address of active adapter' }, + }, + aad_join_status: { + 'aad_join_status.joinType': { type: 'string', description: 'Azure AD join type' }, + 'aad_join_status.tenantId': { type: 'string', description: 'Entra ID tenant' }, + 'aad_join_status.deviceId': { type: 'string', description: 'Entra device object ID' }, + }, +} as const; + +export type ResourceName = 'event_types' | 'device_properties'; + +export function getResourceContent(name: ResourceName): unknown { + switch (name) { + case 'event_types': + return EVENT_TYPES_CATALOG; + case 'device_properties': + return DEVICE_PROPERTIES_CATALOG; + } +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/resources.ts b/src/McpServer/autopilot-monitor-mcp/src/resources.ts index 4bc33b5c..aff847e0 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resources.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resources.ts @@ -1,5 +1,12 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { EVENT_TYPES_CATALOG, DEVICE_PROPERTIES_CATALOG } from './resource-catalog.js'; +/** + * MCP-protocol resources. Note that some clients (e.g. Claude Code's HTTP-MCP + * bridge in stateless mode) do not expose `resources/list` correctly — for + * those clients, use the `get_resource(name)` tool which returns the same + * data via a regular tool call. + */ export function registerResources(server: McpServer): void { server.resource( 'event_types', @@ -13,50 +20,7 @@ export function registerResources(server: McpServer): void { { uri: 'autopilot://event-types', mimeType: 'application/json', - text: JSON.stringify( - { - phase_events: [ - 'phase_transition', - 'esp_state_change', - 'completion_check', - 'enrollment_complete', - 'enrollment_failed', - 'desktop_arrived', - ], - app_events: [ - 'app_install_started', - 'app_install_completed', - 'app_install_failed', - 'app_download_started', - 'app_install_skipped', - ], - network_events: ['network_state_change', 'network_connectivity_check'], - device_info_events: [ - 'os_info', - 'hardware_spec', - 'tpm_status', - 'autopilot_profile', - 'secureboot_status', - 'bitlocker_status', - 'network_adapters', - 'network_interface_info', - 'aad_join_status', - 'enrollment_type_detected', - ], - error_events: ['error_detected'], - vulnerability_events: ['software_inventory_analysis', 'vulnerability_report'], - other: [ - 'performance_snapshot', - 'log_entry', - 'gather_result', - 'script_completed', - 'script_failed', - 'ime_agent_version', - ], - }, - null, - 2 - ), + text: JSON.stringify(EVENT_TYPES_CATALOG, null, 2), }, ], }) @@ -77,68 +41,7 @@ export function registerResources(server: McpServer): void { { uri: 'autopilot://device-properties', mimeType: 'application/json', - text: JSON.stringify( - { - _usage: { - note: 'Use these keys in the deviceProperties parameter of search_sessions.', - booleanFormat: 'Use "True" or "False" (case-insensitive)', - numericOperators: 'Prefix value with >=, <=, >, or < for range filters (e.g. ">=8")', - arraySearch: 'For array properties, value is matched as substring in any array element', - }, - tpm_status: { - 'tpm_status.specVersion': { type: 'string', description: 'TPM specification version (e.g. "2.0", "1.2")' }, - 'tpm_status.manufacturerName': { type: 'string', description: 'TPM manufacturer (e.g. "INTC", "IFX")' }, - 'tpm_status.manufacturerVersion': { type: 'string', description: 'TPM firmware version' }, - 'tpm_status.isActivated': { type: 'boolean', description: 'TPM activation status' }, - 'tpm_status.isEnabled': { type: 'boolean', description: 'TPM enabled status' }, - 'tpm_status.isOwned': { type: 'boolean', description: 'TPM ownership status' }, - }, - secureboot_status: { - 'secureboot_status.uefiSecureBootEnabled': { type: 'boolean', description: 'Secure Boot enabled' }, - }, - bitlocker_status: { - 'bitlocker_status.systemDriveProtected': { type: 'boolean', description: 'System drive (C:) BitLocker protection' }, - 'bitlocker_status.volumes': { type: 'array', description: 'All BitLocker volumes — search by volume letter or protection status' }, - }, - autopilot_profile: { - 'autopilot_profile.autopilotModeLabel': { type: 'string', description: 'Deployment mode (e.g. "User Driven (0)", "Self-Deploying (1)")' }, - 'autopilot_profile.domainJoinMethodLabel': { type: 'string', description: 'Join method (e.g. "Entra Join", "Hybrid Azure AD Join")' }, - 'autopilot_profile.CloudAssignedEspEnabled': { type: 'boolean', description: 'ESP enabled in Autopilot profile' }, - 'autopilot_profile.CloudAssignedDeviceName': { type: 'string', description: 'Cloud-assigned device name template' }, - 'autopilot_profile.CloudAssignedLanguage': { type: 'string', description: 'Cloud-assigned language/locale' }, - }, - hardware_spec: { - 'hardware_spec.cpuName': { type: 'string', description: 'CPU model name (e.g. "Intel Core i7-1265U")' }, - 'hardware_spec.cpuCores': { type: 'number', description: 'Number of physical CPU cores' }, - 'hardware_spec.cpuLogicalProcessors': { type: 'number', description: 'Number of logical processors' }, - 'hardware_spec.ramTotalGB': { type: 'number', description: 'Total RAM in GB. Use >=N for minimum (e.g. ">=8")' }, - 'hardware_spec.ramSpeed': { type: 'string', description: 'RAM speed (e.g. "3200 MHz")' }, - 'hardware_spec.ramType': { type: 'string', description: 'RAM type (e.g. "DDR4", "LPDDR5")' }, - 'hardware_spec.diskCount': { type: 'number', description: 'Number of physical disks' }, - 'hardware_spec.hasSSD': { type: 'boolean', description: 'Has SSD or NVMe storage (computed)' }, - 'hardware_spec.disks': { type: 'array', description: 'Disk details — search for "NVMe", "SSD", disk model names' }, - 'hardware_spec.systemDriveFreeGB': { type: 'number', description: 'Free space on C: in GB' }, - 'hardware_spec.systemDriveTotalGB': { type: 'number', description: 'Total C: drive size in GB' }, - 'hardware_spec.biosVersion': { type: 'string', description: 'BIOS/UEFI version string' }, - 'hardware_spec.batteryPresent': { type: 'boolean', description: 'Device has a battery' }, - 'hardware_spec.batteryHealthPercent': { type: 'number', description: 'Battery health percentage' }, - 'hardware_spec.gpus': { type: 'array', description: 'GPU details — search for GPU model names' }, - }, - network_interface_info: { - 'network_interface_info.connectionType': { type: 'string', description: 'Connection type: "WiFi" or "Ethernet"' }, - 'network_interface_info.linkSpeedMbps': { type: 'number', description: 'Link speed in Mbps' }, - 'network_interface_info.adapterName': { type: 'string', description: 'Network adapter name' }, - 'network_interface_info.macAddress': { type: 'string', description: 'MAC address of active adapter' }, - }, - aad_join_status: { - 'aad_join_status.joinType': { type: 'string', description: 'Azure AD join type' }, - 'aad_join_status.tenantId': { type: 'string', description: 'Entra ID tenant' }, - 'aad_join_status.deviceId': { type: 'string', description: 'Entra device object ID' }, - }, - }, - null, - 2 - ), + text: JSON.stringify(DEVICE_PROPERTIES_CATALOG, null, 2), }, ], }) diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 4c56aec8..7d82fee8 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import { apiFetch, buildQuery, followNextLink, pickGlobalOrTenantPath } from '../client.js'; import { withToolTelemetry } from '../telemetry.js'; +import { getResourceContent } from '../resource-catalog.js'; import { READ_ONLY, READ_ONLY_OPEN, MAX_RESULT_SIZE_CHARS, toolResultText } from './shared.js'; import { toolError } from './error-handler.js'; @@ -519,7 +520,10 @@ export function registerAdminTools(server: McpServer): void { 'Get rule firing statistics for analyze and gather rules. Shows which rules fire most often, ' + 'their hit rates (fires/evaluations), and daily trends. Use to identify commonly triggered rules, ' + 'optimize rule definitions, or understand tenant-specific failure patterns. ' + - 'Without tenantId returns global stats (cross-tenant). With tenantId returns tenant-specific stats.', + 'Without tenantId returns global stats (cross-tenant). With tenantId returns tenant-specific stats. ' + + 'NOTE: default window is 30 days × every rule × per-day trend rows — for 20+ rules the response ' + + 'easily exceeds 70 KB and can trip the response cap. Pass a tighter `startDate`/`endDate` window ' + + '(7 days is usually plenty) and/or `ruleType` filter to keep responses lean.', { tenantId: z.string().optional().describe('Filter by tenant ID. Omit for global (cross-tenant) stats.'), ruleType: z.enum(['analyze', 'gather']).optional().describe('Filter by rule type'), @@ -544,4 +548,28 @@ export function registerAdminTools(server: McpServer): void { } }) ); + + // Tool: get_resource — returns the same content as the MCP `resources` would, + // but via a regular tool call. Workaround for clients (e.g. Claude Code's HTTP-MCP + // bridge in stateless mode) that don't expose `resources/list` reliably. + server.tool( + 'get_resource', + 'Returns the contents of a named static catalog resource. Use this when ' + + 'the host MCP client cannot list/read MCP-protocol resources (common with ' + + 'stateless HTTP MCP servers). Available names:\n' + + ' - "event_types": catalog of valid eventType strings for search_sessions_by_event\n' + + ' - "device_properties": catalog of dot-notation keys for the deviceProperties filter on search_sessions', + { + name: z.enum(['event_types', 'device_properties']).describe('Resource name'), + }, + READ_ONLY_OPEN, + async (args) => withToolTelemetry('get_resource', async () => { + try { + const data = getResourceContent(args.name); + return toolResultText(data, MAX_RESULT_SIZE_CHARS.small); + } catch (error: unknown) { + return toolError('get_resource', args, error); + } + }) + ); } diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 1bae928b..35947fc5 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -38,7 +38,7 @@ export function registerSessionTools(server: McpServer): void { 'Search enrollment sessions. Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant. ' + 'Basic properties (status, serial number, manufacturer, model, etc.) filter on the session index. ' + 'Use deviceProperties for ANY device hardware/config filter — keys use "eventType.propertyName" notation. ' + - 'Consult the device_properties resource for available keys. ' + + 'Consult the device_properties catalog (call get_resource(name="device_properties")) for available keys. ' + 'Examples: {"tpm_status.specVersion": "2.0"}, {"hardware_spec.ramTotalGB": ">=8"}, {"secureboot_status.uefiSecureBootEnabled": "True"}. ' + 'Array values are searched as substring match (e.g. disks containing "NVMe"). ' + 'For COUNTING / AGGREGATION queries (e.g. "how many V2 enrollments?", "how many failed in last 7 days?") ALWAYS pass ' + @@ -78,7 +78,7 @@ export function registerSessionTools(server: McpServer): void { 'isUserDriven, isHybridJoin, agentVersion, imeAgentVersion, geoCountry.'), deviceProperties: z.record(z.string(), z.string()).optional().describe( 'Dynamic device property filters. Keys use "eventType.propertyName" dot notation. ' + - 'See the device_properties resource for all available keys and types. ' + + 'See the device_properties catalog (call get_resource(name="device_properties")) for all available keys and types. ' + 'Values: exact match by default. Prefix with >=, <=, >, < for numeric ranges (e.g. ">=8"). ' + 'Booleans: use "True" or "False". Arrays: substring match in any element.' ), @@ -116,14 +116,14 @@ export function registerSessionTools(server: McpServer): void { server.tool( 'search_sessions_by_event', 'Find sessions that contain a specific event type (e.g. app install failure, phase transitions, errors). ' + - 'Omit tenantId for cross-tenant search (Global Admin). Check the event_types resource for valid eventType values. ' + + 'Omit tenantId for cross-tenant search (Global Admin). Check the event_types catalog (call get_resource(name="event_types")) for valid eventType values. ' + 'Use this to answer: which devices had a failed Teams install, which sessions had an error in DeviceSetup phase. ' + 'This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical ' + 'interactive queries; raise it (up to 1000) for full sweeps. For broad analysis, use pageSize=1000 and follow ' + 'nextLink repeatedly until absent. Pass the whole nextLink string as "continuation" so all backend-echoed query ' + 'params round-trip correctly.', { - eventType: z.string().describe('Event type string — see event_types resource for valid values (e.g. "app_install_failed", "enrollment_failed")'), + eventType: z.string().describe('Event type string — see event_types catalog (call get_resource(name="event_types")) for valid values (e.g. "app_install_failed", "enrollment_failed")'), tenantId: z.string().optional().describe('Tenant ID. Omit for cross-tenant search (Global Admin only).'), pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) .describe('Page size (1-1000, default 200). Returns this many sessions per call; follow nextLink to fetch more.'), From b692baa5d91241b01f678bcda5b9a2d4c08c46c0 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 7 May 2026 16:41:22 +0200 Subject: [PATCH 7/7] =?UTF-8?q?feat(mcp):=20get=5Fsession=5Fsummary=20lean?= =?UTF-8?q?=20=E2=80=94=20cap=20key=20events=20+=20drop=20heavy=20payloads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary used to embed every key event's full data/dataJson payload — one busy session could push the response well past 80 KB and trip the response cap. The point of the summary is triage, not raw payload delivery; users wanting full event data already have get_session_events. Now: * key events sorted by relevance (Errors > phase transitions > Warnings > others), capped at 50 entries; full timeline still drives stats * the displayed slice is re-sorted chronologically for readability * heavy `details` field dropped — was the main bloat source * stats.keyEventsTotal + keyEventsTruncated exposed so callers know if more events were filtered out Description updated to set expectations + point at get_session_events for full payloads. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/tools/sessions.ts | 45 ++++++++++++++++--- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 35947fc5..aea7eb5a 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -227,8 +227,10 @@ export function registerSessionTools(server: McpServer): void { 'get_session_summary', 'Get a concise, structured summary of an enrollment session optimized for analysis. ' + 'Returns: session overview (status, duration, device, enrollment config), ' + - 'key events timeline (errors, warnings, phase transitions, app installs — noise filtered out), ' + - 'rule analysis results (probable cause, remediation), and aggregate stats. ' + + 'key events timeline (errors, warnings, phase transitions, app installs — noise filtered out, ' + + 'capped at 50 most-relevant entries; stats.keyEventsTruncated indicates if more were dropped), ' + + 'rule analysis results (probable cause, remediation), and aggregate stats. Heavy event payloads ' + + '(data JSON) are NOT included — pull them via get_session_events for the same sessionId when needed. ' + 'Use this as the FIRST tool when investigating a session. ' + 'For raw unfiltered events use get_session_events. For full metadata use get_session.', { @@ -299,23 +301,50 @@ export function registerSessionTools(server: McpServer): void { if (et === 'app_install_skipped') appSkipped++; } - let keyEvents = allEvents.filter((e) => { + // Triage timeline: keep noise-free events, sort by relevance (errors > + // phase transitions > warnings > others, then chronological), cap at 50 + // entries. Heavy `details` payloads are dropped by default — they were the + // root cause of the previous 80 KB+ responses; callers needing full payloads + // pull them via get_session_events with the same sessionId. + const KEY_EVENTS_CAP = 50; + const PHASE_EVENT_TYPES = new Set([ + 'phase_transition', 'esp_phase_changed', 'enrollment_type_detected', + 'enrollment_complete', 'enrollment_failed', 'desktop_arrived', + ]); + const allKey = allEvents.filter((e) => { const et = String(e.eventType ?? ''); if (EXCLUDED_EVENT_TYPES.has(et)) return false; if (KEY_EVENT_TYPES.has(et)) return true; return (SEVERITY_RANK[String(e.severity ?? '')] ?? -1) >= 2; }); - const mappedEvents = keyEvents.map((e) => ({ + const relevanceScore = (e: Record): number => { + const sev = SEVERITY_RANK[String(e.severity ?? '')] ?? -1; + if (sev >= 3) return 100; // Error/Critical + if (PHASE_EVENT_TYPES.has(String(e.eventType ?? ''))) return 60; + if (sev === 2) return 30; // Warning + return 10; // info-level key event + }; + + const sortedKey = [...allKey].sort((a, b) => { + const r = relevanceScore(b) - relevanceScore(a); + if (r !== 0) return r; + return String(a.timestamp ?? '').localeCompare(String(b.timestamp ?? '')); + }); + + const truncated = sortedKey.length > KEY_EVENTS_CAP; + const cappedKey = truncated ? sortedKey.slice(0, KEY_EVENTS_CAP) : sortedKey; + + // Re-sort the displayed slice chronologically — easier to read as a timeline. + cappedKey.sort((a, b) => String(a.timestamp ?? '').localeCompare(String(b.timestamp ?? ''))); + + const mappedEvents = cappedKey.map((e) => ({ timestamp: e.timestamp, eventType: e.eventType, severity: e.severity, phase: PHASE_NAMES[Number(e.phase)] ?? String(e.phase ?? ''), message: e.message, source: e.source, - details: e.data && typeof e.data === 'object' && Object.keys(e.data as object).length > 0 - ? e.data - : (e.dataJson ? (() => { try { return JSON.parse(String(e.dataJson)); } catch { return null; } })() : null), })); let analysis = null; @@ -342,7 +371,9 @@ export function registerSessionTools(server: McpServer): void { analysis, stats: { totalEvents: allEvents.length, + keyEventsTotal: sortedKey.length, keyEventsShown: mappedEvents.length, + keyEventsTruncated: truncated, errorCount, warningCount, appInstalls: { total: appTotal, succeeded: appSucceeded, failed: appFailed, skipped: appSkipped },