mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
Merge pull request #60 from okieselbach/mcp-routing-and-cleanup
Mcp routing and cleanup
This commit is contained in:
+30
-10
@@ -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;
|
||||
}
|
||||
|
||||
+43
-3
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+21
-5
@@ -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)
|
||||
|
||||
+63
-5
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user