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, }); } 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/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/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(); } } 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() 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/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 e1abad49..7d82fee8 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -1,7 +1,8 @@ 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 { 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'; @@ -58,7 +59,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 +94,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 +233,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 +348,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 +395,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, @@ -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/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..aea7eb5a 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'; @@ -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.' ), @@ -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 @@ -115,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.'), @@ -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); @@ -236,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.', { @@ -308,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; @@ -351,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 }, @@ -383,7 +405,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 +441,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 +468,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) { 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(),