Merge pull request #60 from okieselbach/mcp-routing-and-cleanup

Mcp routing and cleanup
This commit is contained in:
Oliver Kieselbach
2026-05-07 16:44:38 +02:00
committed by GitHub
16 changed files with 495 additions and 210 deletions
@@ -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<string, string?>("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
@@ -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<string, string?>("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<KeyValuePair<string, string?>>();
if (!string.IsNullOrEmpty(category))
extrasList.Add(new KeyValuePair<string, string?>("category", category));
if (!string.IsNullOrEmpty(filterTenantId))
extrasList.Add(new KeyValuePair<string, string?>("tenantId", filterTenantId));
var extras = extrasList.Count > 0 ? extrasList.ToArray() : null;
static IEnumerable<OpsEventEntry> ApplyTenantFilter(
IEnumerable<OpsEventEntry> 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,
});
}
@@ -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;
}
@@ -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;
}
/// <summary>
/// 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 <see cref="LocationSessionRow"/>. Callers needing the full shape
/// pass <c>?full=1</c>.
/// </summary>
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,
};
}
}
@@ -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)
@@ -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<EnrollmentEvent> ApplyFilters(
IEnumerable<EnrollmentEvent> source,
string? eventType, string? severity, string? sourceFilter)
{
EventSeverity? wantSeverity = null;
if (!string.IsNullOrEmpty(severity)
&& Enum.TryParse<EventSeverity>(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<KeyValuePair<string, string?>>(3);
if (!string.IsNullOrEmpty(filterEventType))
extras.Add(new KeyValuePair<string, string?>("eventType", filterEventType));
if (!string.IsNullOrEmpty(filterSeverity))
extras.Add(new KeyValuePair<string, string?>("severity", filterSeverity));
if (!string.IsNullOrEmpty(filterSource))
extras.Add(new KeyValuePair<string, string?>("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,
});
}
@@ -100,7 +100,8 @@ namespace AutopilotMonitor.Functions.Pagination
string sessionId,
int pageSize,
string wireContinuation,
string? tenantId)
string? tenantId,
IEnumerable<KeyValuePair<string, string?>>? 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();
}
}
@@ -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<McpAccessPolicy>(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()
@@ -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<string, AccessCacheEntry>();
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<AccessCheckResult> {
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' });
@@ -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<string>();
interface CallerContext {
token: string;
isGlobalAdmin: boolean;
}
const callerStore = new AsyncLocalStorage<CallerContext>();
/**
* 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<T>(token: string, fn: () => T): T {
return tokenStore.run(token, fn);
export function runWithCaller<T>(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;
}
/**
@@ -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;
}
}
@@ -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),
},
],
})
@@ -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<string, string | number | undefined> = { ...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);
}
})
);
}
@@ -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<string, string | number | undefined> = { 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 }>;
};
@@ -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.<key>` 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<string, unknown>): 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<string, string | number | undefined> = { ...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<string, string | undefined>)}`
: '/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<string, string | undefined>)}`;
const data = await apiFetch(endpoint);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.adminStream);
} catch (error: unknown) {
+5 -2
View File
@@ -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(),