improve MCP server: audit fixes + split tools into domain modules

- Add tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) to all 23 tools
- Add isError flag to error responses (403, 503, KB not ready) for proper MCP error signaling
- Replace global token state with AsyncLocalStorage to prevent cross-session token leaks
- Add session TTL cleanup (12h interval, 2h max age) for abandoned sessions
- DRY server version string from package.json instead of 3x hardcoded
- Split 1060-line tools.ts into domain modules: sessions (359), search (353), admin (351)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-03-31 01:10:16 +02:00
parent 3cfe88c791
commit 4cf169f8af
8 changed files with 1145 additions and 1058 deletions
@@ -9,7 +9,7 @@
*/
import type { Request, Response, NextFunction } from 'express';
import { extractTokenClaims, isTokenExpired } from './auth.js';
import { setCurrentToken } from './client.js';
import { setCurrentToken, runWithToken } from './client.js';
const BASE_URL = process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.azurewebsites.net';
@@ -148,9 +148,12 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
return;
}
// Set token for pass-through to backend API
// Set token for pass-through to backend API.
// runWithToken scopes the token to this async context so concurrent
// sessions cannot overwrite each other's tokens on the event loop.
// setCurrentToken is kept as fallback during the transition.
setCurrentToken(token);
next();
runWithToken(token, () => next());
})
.catch(() => {
res.status(503).json({ error: 'Access check failed' });
@@ -1,29 +1,45 @@
import { AsyncLocalStorage } from 'node:async_hooks';
const BASE_URL = process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.azurewebsites.net';
/** Default timeout for backend API requests (30 seconds) */
const API_TIMEOUT_MS = 30_000;
/**
* Per-request token store. The MCP request handler sets the current user's
* Bearer token before tool execution, and apiFetch reads it to pass through
* to the backend API.
* Per-request token store using AsyncLocalStorage.
*
* NOTE: This is safe in the MCP server context because each MCP request is
* handled sequentially within a session — the token is set before tool
* execution and read during it, with no concurrent overlap.
* 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.
*/
let _currentToken: string | undefined;
const tokenStore = new AsyncLocalStorage<string>();
/**
* 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.
*/
export function runWithToken<T>(token: string, fn: () => T): T {
return tokenStore.run(token, fn);
}
/**
* @deprecated Use `runWithToken` instead. Kept for backward compatibility
* during the transition — sets a fallback global token for code paths that
* haven't migrated to `runWithToken` yet.
*/
let _fallbackToken: string | undefined;
export function setCurrentToken(token: string | undefined): void {
_currentToken = token;
_fallbackToken = token;
}
export function getCurrentToken(): string | undefined {
return _currentToken;
return tokenStore.getStore() ?? _fallbackToken;
}
async function apiFetch(path: string, options: RequestInit = {}): Promise<unknown> {
const token = _currentToken;
const token = getCurrentToken();
if (!token) {
throw new Error('No authentication token available. Ensure the request includes a valid Bearer token.');
}
@@ -1,4 +1,5 @@
import { resolve, dirname } from 'node:path';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import express from 'express';
@@ -13,6 +14,9 @@ import { accessGuard } from './access-guard.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')) as { version: string };
const SERVER_VERSION: string = pkg.version;
const PORT = parseInt(process.env.PORT ?? '8080', 10);
const RULES_DIR = process.env.RULES_DIR ?? resolve(__dirname, '..', '..', '..', '..', 'rules');
@@ -28,7 +32,7 @@ console.error(`Search provider ready: ${knowledgeBase.name} — ${knowledgeBase.
/** Creates a fresh McpServer instance per session (each needs its own protocol). */
function createMcpServer(): McpServer {
const s = new McpServer({ name: 'Autopilot-Monitor', version: '1.2.0' });
const s = new McpServer({ name: 'Autopilot-Monitor', version: SERVER_VERSION });
registerTools(s, knowledgeBase);
registerResources(s);
return s;
@@ -48,13 +52,27 @@ app.get('/health', (_req, res) => {
res.json({
status: 'healthy',
server: 'autopilot-monitor-mcp',
version: '1.2.0',
version: SERVER_VERSION,
search: { backend: knowledgeBase.name, documents: knowledgeBase.size },
});
});
// Track transports by session ID for reuse
const transports = new Map<string, StreamableHTTPServerTransport>();
const transports = new Map<string, { transport: StreamableHTTPServerTransport; createdAt: number }>();
// Session TTL cleanup — remove abandoned sessions that never sent a DELETE.
// Currently runs every 12h (cost-effective for single-user on a sleeping container app).
// TODO: Reduce interval (e.g. 30min) when scaling to multiple concurrent users.
const SESSION_MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours
setInterval(() => {
const now = Date.now();
for (const [id, entry] of transports) {
if (now - entry.createdAt > SESSION_MAX_AGE_MS) {
transports.delete(id);
console.error(`[mcp] Reaped stale session ${id} (age > ${SESSION_MAX_AGE_MS / 1000 / 60}min)`);
}
}
}, 12 * 60 * 60 * 1000); // 12h interval
// Access guard for /mcp — validates JWT, checks backend whitelist, enforces rate limits
app.use('/mcp', accessGuard);
@@ -65,19 +83,19 @@ app.all('/mcp', async (req, res) => {
// GET (SSE stream) or DELETE (session termination) — need existing session
if (req.method === 'GET' || req.method === 'DELETE') {
const transport = sessionId ? transports.get(sessionId) : undefined;
if (!transport) {
const entry = sessionId ? transports.get(sessionId) : undefined;
if (!entry) {
res.status(400).json({ error: 'No valid session. Send an initialize request first.' });
return;
}
await transport.handleRequest(req, res);
await entry.transport.handleRequest(req, res);
return;
}
// POST — existing session: forward to its transport
if (sessionId && transports.has(sessionId)) {
const transport = transports.get(sessionId)!;
await transport.handleRequest(req, res, req.body);
const entry = transports.get(sessionId)!;
await entry.transport.handleRequest(req, res, req.body);
return;
}
@@ -105,7 +123,7 @@ app.all('/mcp', async (req, res) => {
// Register AFTER handleRequest — the session ID is only assigned during
// initialize processing inside handleRequest, not before.
if (transport.sessionId) {
transports.set(transport.sessionId, transport);
transports.set(transport.sessionId, { transport, createdAt: Date.now() });
console.error(`[mcp] Session ${transport.sessionId} registered`);
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,351 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { apiFetch, buildQuery } from '../client.js';
import { READ_ONLY, READ_ONLY_OPEN } from './shared.js';
export function registerAdminTools(server: McpServer): void {
// Tool 11: get_api_usage
server.tool(
'get_api_usage',
'Get API/MCP usage statistics. Shows request counts per endpoint per day. ' +
'Use to monitor platform usage, identify heavy users, or debug rate limiting. ' +
'Use userId for a specific user, daily for aggregated summaries, or neither for global per-record breakdown.',
{
userId: z.string().optional().describe('Specific user object ID to query usage for'),
tenantId: z.string().optional().describe('Filter usage by tenant ID'),
dateFrom: z.string().optional().describe('Start date (YYYY-MM-DD)'),
dateTo: z.string().optional().describe('End date (YYYY-MM-DD)'),
daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'),
},
READ_ONLY,
async (args) => {
try {
let data: unknown;
const params: Record<string, string | undefined> = { tenantId: args.tenantId, dateFrom: args.dateFrom, dateTo: args.dateTo };
if (args.userId) {
data = await apiFetch(`/api/metrics/mcp-usage/${encodeURIComponent(args.userId)}${buildQuery(params)}`);
} else if (args.daily) {
data = await apiFetch(`/api/global/metrics/mcp-usage/daily${buildQuery(params)}`);
} else {
data = await apiFetch(`/api/global/metrics/mcp-usage${buildQuery(params)}`);
}
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('403')) {
return { isError: true, content: [{ type: 'text' as const, text: 'Access denied. This tool requires Global Admin permissions.' }] };
}
throw error;
}
}
);
// Tool 12: get_geographic_metrics
server.tool(
'get_geographic_metrics',
'Get geographic distribution of enrollments — where devices are enrolling from, with performance comparisons. ' +
'Shows per-location: session counts, success rates, avg/median/p95 duration, throughput, and outlier detection. ' +
'Omit tenantId for cross-tenant view (Global Admin). Use get_geographic_sessions to drill into a specific location.',
{
tenantId: z.string().optional().describe('Tenant ID. Omit for cross-tenant view (Global Admin only).'),
days: z.coerce.number().optional().default(30).describe('Time range in days (default: 30)'),
groupBy: z.enum(['country', 'region', 'city']).optional().default('city')
.describe('Geographic grouping level (default: "city")'),
},
READ_ONLY,
async ({ tenantId, ...rest }) => {
const params: Record<string, string | number | undefined> = { ...rest };
if (tenantId) params.tenantId = tenantId;
const prefix = tenantId ? '/api/metrics' : '/api/global/metrics';
const data = await apiFetch(`${prefix}/geographic${buildQuery(params)}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// Tool 13: get_geographic_sessions
server.tool(
'get_geographic_sessions',
'Drill into a specific geographic location. Returns all enrollment sessions at that location. ' +
'Either provide locationKey (from get_geographic_metrics results, e.g. "Falkenstein, Saxony, DE") ' +
'or use country/region/city filters to find sessions by location.',
{
locationKey: z.string().optional().describe('Location key from get_geographic_metrics (e.g. "Falkenstein, Saxony, DE"). If provided, country/region/city are ignored.'),
country: z.string().optional().describe('2-letter country code filter (e.g. "DE", "US", "CH"). Used when locationKey is not provided.'),
region: z.string().optional().describe('Region/state filter (e.g. "Saxony", "North Carolina"). Used with country.'),
city: z.string().optional().describe('City filter (e.g. "Falkenstein"). Used with country.'),
tenantId: z.string().optional().describe('Tenant ID. Omit for cross-tenant view (Global Admin only).'),
days: z.coerce.number().optional().default(30).describe('Time range in days (default: 30)'),
},
READ_ONLY,
async ({ tenantId, country, region, city, ...rest }) => {
const params: Record<string, string | number | undefined> = { ...rest };
if (tenantId) params.tenantId = tenantId;
if (!params.locationKey && country) {
const parts = [city, region, country].filter(Boolean);
params.locationKey = parts.join(', ');
params.groupBy = city ? 'city' : region ? 'region' : 'country';
}
const prefix = tenantId ? '/api/metrics' : '/api/global/metrics';
const data = await apiFetch(`${prefix}/geographic/sessions${buildQuery(params)}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// Tool 14: get_platform_metrics
server.tool(
'get_platform_metrics',
'Get aggregated platform-level agent performance metrics across recent sessions. ' +
'Returns: avg/max/p95 CPU, memory (working set, private bytes), network (bytes up/down, latency, requests), ' +
'top sessions by CPU/memory, and per-agent-version breakdown. Global Admin only.',
{},
READ_ONLY,
async () => {
type SessionMetric = {
sessionId: string; tenantId: string; deviceName?: string; model?: string; status?: string;
agentVersion?: string; snapshotCount: number;
avgCpu: number; maxCpu: number; avgWorkingSet: number; maxWorkingSet: number;
avgPrivateBytes: number; avgLatency: number;
totalBytesUp: number; totalBytesDown: number; totalRequests: number;
};
const raw = await apiFetch('/api/global/metrics/platform') as { sessions?: SessionMetric[] };
const sessions = raw?.sessions ?? [];
if (sessions.length === 0) {
return { content: [{ type: 'text' as const, text: JSON.stringify({ sessionsAnalyzed: 0, message: 'No performance data available' }) }] };
}
const avg = (arr: number[]) => arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const p95 = (arr: number[]) => { const s = [...arr].sort((a, b) => a - b); return s[Math.floor(s.length * 0.95)] ?? 0; };
const round = (n: number) => Math.round(n * 100) / 100;
const cpus = sessions.map(s => s.avgCpu);
const maxCpus = sessions.map(s => s.maxCpu);
const ws = sessions.map(s => s.avgWorkingSet);
const pb = sessions.map(s => s.avgPrivateBytes);
const lat = sessions.filter(s => s.avgLatency > 0).map(s => s.avgLatency);
const topCpu = [...sessions].sort((a, b) => b.maxCpu - a.maxCpu).slice(0, 5).map(s => ({
sessionId: s.sessionId, device: s.deviceName, model: s.model, maxCpu: round(s.maxCpu), avgCpu: round(s.avgCpu),
}));
const topMem = [...sessions].sort((a, b) => b.avgWorkingSet - a.avgWorkingSet).slice(0, 5).map(s => ({
sessionId: s.sessionId, device: s.deviceName, model: s.model, avgWorkingSetMB: round(s.avgWorkingSet),
}));
const byVersion: Record<string, { count: number; avgCpu: number[]; avgMem: number[] }> = {};
for (const s of sessions) {
const v = s.agentVersion ?? 'unknown';
if (!byVersion[v]) byVersion[v] = { count: 0, avgCpu: [], avgMem: [] };
byVersion[v].count++;
byVersion[v].avgCpu.push(s.avgCpu);
byVersion[v].avgMem.push(s.avgWorkingSet);
}
const versionBreakdown = Object.entries(byVersion).map(([version, d]) => ({
version, sessions: d.count, avgCpu: round(avg(d.avgCpu)), avgMemMB: round(avg(d.avgMem)),
})).sort((a, b) => b.sessions - a.sessions);
const summary = {
sessionsAnalyzed: sessions.length,
cpu: { avgPercent: round(avg(cpus)), maxPercent: round(Math.max(...maxCpus)), p95Percent: round(p95(maxCpus)) },
memory: {
avgWorkingSetMB: round(avg(ws)), maxWorkingSetMB: round(Math.max(...ws)), p95WorkingSetMB: round(p95(ws)),
avgPrivateBytesMB: round(avg(pb)),
},
network: {
totalBytesUp: sessions.reduce((a, s) => a + s.totalBytesUp, 0),
totalBytesDown: sessions.reduce((a, s) => a + s.totalBytesDown, 0),
totalRequests: sessions.reduce((a, s) => a + s.totalRequests, 0),
avgLatencyMs: round(avg(lat)),
},
topSessionsByCpu: topCpu,
topSessionsByMemory: topMem,
agentVersionBreakdown: versionBreakdown,
};
return { content: [{ type: 'text' as const, text: JSON.stringify(summary, null, 2) }] };
}
);
// Tool 15: get_usage_metrics
server.tool(
'get_usage_metrics',
'Get platform usage statistics: active tenants, session volumes, feature adoption. ' +
'Omit tenantId for platform-wide overview (Global Admin), or specify tenantId for tenant-specific usage.',
{
tenantId: z.string().optional().describe('Tenant ID for tenant-specific metrics. Omit for platform-wide overview (Global Admin only).'),
},
READ_ONLY,
async ({ tenantId }) => {
if (tenantId) {
const data = await apiFetch(`/api/global/metrics/usage${buildQuery({ tenantId })}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
const data = await apiFetch('/api/global/metrics/usage');
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// Tool 16: get_audit_logs
server.tool(
'get_audit_logs',
'Get audit trail of administrative actions: config changes, device blocks, user management, report submissions. ' +
'Omit tenantId for cross-tenant audit log (Global Admin). Returns up to 100 most recent entries.',
{
tenantId: z.string().optional().describe('Tenant ID for tenant-scoped audit log. Omit for cross-tenant view (Global Admin only).'),
},
READ_ONLY,
async ({ tenantId }) => {
const endpoint = tenantId
? `/api/audit/logs${buildQuery({ tenantId })}`
: '/api/global/audit/logs';
const data = await apiFetch(endpoint);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// Tool 17: list_session_reports
server.tool(
'list_session_reports',
'List session reports submitted by tenant admins. Reports contain user comments, screenshots, and agent logs for troubleshooting. ' +
'Global Admin only — returns reports across all tenants.',
{},
READ_ONLY,
async () => {
const data = await apiFetch('/api/global/session-reports');
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// ── Raw Data Tools ────────────────────────────────────────────────────
// Tool 18: query_raw_events
server.tool(
'query_raw_events',
'TIER 2 — RAW CROSS-SESSION EVENT QUERY (fallback for broader scope). ' +
'Query raw enrollment events with flexible filters across sessions within a tenant. ' +
'tenantId is always required (events are partitioned per tenant). Global Admin can query any tenant. ' +
'Use this when search_events_semantic does not cover the time range or session scope you need, ' +
'or when you need exact event-type filtering across many sessions. Returns raw event data.',
{
tenantId: z.string().describe('Tenant ID to query (required). Global Admin can query any tenant.'),
sessionId: z.string().optional().describe('Filter to a specific session'),
eventType: z.string().optional().describe('Event type filter (e.g. "app_install_failed", "error_detected")'),
severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(),
source: z.string().optional().describe('Filter by event source/app name (substring match)'),
startedAfter: z.string().optional().describe('ISO 8601 datetime — only events after this'),
startedBefore: z.string().optional().describe('ISO 8601 datetime — only events before this'),
limit: z.coerce.number().min(1).max(500).optional().default(100),
},
READ_ONLY,
async (args) => {
const { tenantId, ...rest } = args;
const params: Record<string, string | number | boolean | undefined | null> = { ...rest, tenantId };
const data = await apiFetch(`/api/raw/events${buildQuery(params)}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// Tool 19: query_raw_sessions
server.tool(
'query_raw_sessions',
'Query raw session data with flexible filters and field projection. ' +
'Specify tenantId for a specific tenant, or omit for cross-tenant access (Global Admin only). ' +
'Use the fields parameter to select specific properties (e.g. "sessionId,status,startedAt,serialNumber"). Returns raw session entities.',
{
tenantId: z.string().optional().describe('Tenant ID to query. Omit for cross-tenant access (Global Admin only).'),
status: z.enum(['InProgress', 'Succeeded', 'Failed']).optional(),
startedAfter: z.string().optional().describe('ISO 8601 datetime'),
startedBefore: z.string().optional().describe('ISO 8601 datetime'),
serialNumber: z.string().optional(),
fields: z.string().optional().describe('Comma-separated fields to return (e.g. "sessionId,status,startedAt,serialNumber,durationSeconds")'),
limit: z.coerce.number().min(1).max(200).optional().default(50),
},
READ_ONLY,
async (args) => {
const { tenantId, ...rest } = args;
const params: Record<string, string | number | boolean | undefined | null> = { ...rest };
const basePath = tenantId ? '/api/raw/sessions' : '/api/global/raw/sessions';
if (tenantId) params.tenantId = tenantId;
const data = await apiFetch(`${basePath}${buildQuery(params)}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// ── Admin Diagnostic Tools ────────────────────────────────────────────
// Tool 20: list_tables
server.tool(
'list_tables',
'List all available Azure Table Storage tables that can be queried via query_table. Global Admin only.',
{},
READ_ONLY,
async () => {
try {
const data = await apiFetch('/api/global/raw/tables');
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('403')) {
return { isError: true, content: [{ type: 'text' as const, text: 'Access denied. This tool requires Global Admin permissions.' }] };
}
throw error;
}
}
);
// Tool 21: query_table
server.tool(
'query_table',
'Query any Azure Table Storage table directly with OData filters. Global Admin only. ' +
'Use list_tables to see available tables. Useful for inspecting TenantConfiguration, RuleResults, or any raw data.',
{
tableName: z.string().describe('Table name (e.g. "Sessions", "Events", "RuleResults", "TenantConfiguration")'),
partitionKey: z.string().optional().describe('Filter by exact partition key (usually TenantId)'),
rowKeyPrefix: z.string().optional().describe('Filter by row key prefix'),
filter: z.string().optional().describe('OData filter expression (e.g. "Status eq \'Failed\'")'),
limit: z.coerce.number().min(1).max(500).optional().default(100),
},
READ_ONLY,
async (args) => {
try {
const { tableName, ...rest } = args;
const data = await apiFetch(`/api/global/raw/tables/${encodeURIComponent(tableName)}${buildQuery(rest)}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('403')) {
return { isError: true, content: [{ type: 'text' as const, text: 'Access denied. This tool requires Global Admin permissions.' }] };
}
throw error;
}
}
);
// Tool 22: query_backend_logs
server.tool(
'query_backend_logs',
'Query backend Application Insights logs using KQL. Global Admin only. ' +
'Use for debugging backend issues, tracing requests by correlation ID, and platform diagnostics.',
{
query: z.string().describe('KQL query (e.g. "traces | where message contains \'error\' | take 50")'),
timespan: z.string().optional().default('PT1H').describe('ISO 8601 duration (default: PT1H = last 1 hour). Examples: PT30M, PT6H, P1D'),
},
READ_ONLY_OPEN,
async (args) => {
try {
const data = await apiFetch('/api/global/raw/logs', {
method: 'POST',
body: JSON.stringify({ query: args.query, timespan: args.timespan }),
});
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('403')) {
return { isError: true, content: [{ type: 'text' as const, text: 'Access denied. This tool requires Global Admin permissions.' }] };
}
if (message.includes('503')) {
return { isError: true, content: [{ type: 'text' as const, text: 'Application Insights diagnostics is not configured. Set APPINSIGHTS_APP_ID and assign Monitoring Reader role to the Function App Managed Identity.' }] };
}
throw error;
}
}
);
}
@@ -0,0 +1,353 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { apiFetch, buildQuery } from '../client.js';
import type { SearchProvider } from '../search-provider.js';
import { createSearchProvider } from '../search-factory.js';
import { READ_ONLY } from './shared.js';
// ── Helpers ─────────────────────────────────────────────────────────────
type EventEntry = {
eventType?: string; severity?: string; source?: string;
message?: string; timestamp?: string; phase?: string;
data?: Record<string, unknown>; _sessionId?: string;
};
/** Fetch events for a single session or across recent failed sessions. */
async function fetchSessionEvents(
sessionId: string | undefined,
tenantId: string | undefined,
): Promise<{ events: EventEntry[]; sessionIds: string[] }> {
if (sessionId) {
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
const data = await apiFetch(`/api/sessions/${sessionId}/events${q}`) as { events?: EventEntry[] };
return { events: data?.events ?? [], sessionIds: [sessionId] };
}
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 sessions = await apiFetch(`${searchBase}${searchQ}`) as {
sessions?: Array<{ sessionId?: string }>;
};
const ids = (sessions?.sessions ?? []).map((s) => s.sessionId).filter(Boolean) as string[];
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
const allEvents = await Promise.all(
ids.map(async (sid) => {
try {
const d = await apiFetch(`/api/sessions/${sid}/events${q}`) as { events?: EventEntry[] };
return (d?.events ?? []).map((e) => ({ ...e, _sessionId: sid }));
} catch { return [] as EventEntry[]; }
})
);
return { events: allEvents.flat(), sessionIds: ids };
}
const KEYWORD_STOP_WORDS = new Set([
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for',
'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during',
'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over',
'under', 'again', 'further', 'then', 'once', 'and', 'but', 'or', 'nor',
'not', 'so', 'yet', 'both', 'either', 'neither', 'each', 'every', 'all',
'any', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'only',
'own', 'same', 'than', 'too', 'very', 'just', 'about', 'also', 'it',
'its', 'this', 'that', 'these', 'those', 'what', 'which', 'who', 'whom',
'how', 'when', 'where', 'why', 'find', 'search', 'show', 'get', 'events',
'event', 'check', 'look', 'see',
]);
/** Extract meaningful keywords from a natural language query. */
function extractKeywords(query: string): string[] {
return query
.toLowerCase()
.replace(/[^\w\s-]/g, ' ')
.split(/\s+/)
.filter((w) => w.length > 2 && !KEYWORD_STOP_WORDS.has(w));
}
// ── Registration ────────────────────────────────────────────────────────
export function registerSearchTools(server: McpServer, knowledgeBase?: SearchProvider): void {
// Tool 9: search_events_semantic
server.tool(
'search_events_semantic',
'TIER 1 — FAST SEMANTIC SEARCH (try this first). ' +
'Semantic/fuzzy search over enrollment event messages within a session or across recent failed sessions. ' +
'Finds events by MEANING — e.g. "network timeout" also matches "connection timed out", "request failed after waiting". ' +
'Use this when you need to find events matching a symptom description rather than an exact event type. ' +
'Provide sessionId to search within one session, or omit to search across recent failed sessions. ' +
'If results seem incomplete or you need guaranteed completeness, escalate to deep_search_events.',
{
query: z.string().describe('Natural language description of what to find (e.g. "app download stuck", "certificate error", "disk space low")'),
sessionId: z.string().optional().describe('Search within a specific session. If omitted, searches across recent failed sessions.'),
tenantId: z.string().optional().describe('Tenant ID. Required for non-Global Admin users; Global Admin can omit to search across tenants.'),
topK: z.coerce.number().min(1).max(30).optional().default(10).describe('Number of matching events to return (1-30, default 10)'),
minScore: z.coerce.number().min(0).max(1).optional().default(0.35)
.describe('Minimum similarity score (0-1, default 0.35)'),
},
READ_ONLY,
async ({ query, sessionId, tenantId, topK, minScore }) => {
const { events, sessionIds } = await fetchSessionEvents(sessionId, tenantId);
const candidates = events.filter((e) => e.message && e.message.length > 5);
if (candidates.length === 0) {
return {
content: [{
type: 'text' as const,
text: JSON.stringify({ query, resultCount: 0, results: [], note: 'No events with messages found.' }),
}],
};
}
const docs = candidates.map((e, i) => {
const parts = [e.message];
if (e.eventType) parts.push(`Event: ${e.eventType}`);
if (e.severity) parts.push(`Severity: ${e.severity}`);
if (e.source) parts.push(`Source: ${e.source}`);
return {
id: `event-${i}`,
text: parts.join(' | '),
metadata: { index: i } as Record<string, unknown>,
};
});
const provider = await createSearchProvider();
await provider.index(docs);
const searchResults = await provider.search(query, { topK, minScore });
const results = searchResults.map((r) => {
const idx = r.metadata.index as number;
const e = candidates[idx];
return {
score: Math.round(r.score * 1000) / 1000,
sessionId: e._sessionId ?? sessionId,
eventType: e.eventType,
severity: e.severity,
source: e.source,
phase: e.phase,
timestamp: e.timestamp,
message: e.message,
};
});
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
query,
searchBackend: provider.name,
sessionsSearched: sessionIds,
eventsAnalyzed: candidates.length,
resultCount: results.length,
results,
}, null, 2),
}],
};
}
);
// Tool 10: search_knowledge
server.tool(
'search_knowledge',
'Semantic/fuzzy search over the Autopilot Monitor knowledge base: analysis rules, gather rules, and IME log patterns. ' +
'Use natural language queries like "app install timeout", "BitLocker issues", "detection script failure". ' +
'Returns the most relevant rules and patterns ranked by similarity. ' +
'Great for finding remediation steps, understanding error patterns, or discovering relevant diagnostic rules.',
{
query: z.string().describe('Natural language search query (e.g. "app download timeout", "TPM not ready", "ESP stuck")'),
topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'),
type: z.enum(['all', 'analyze-rule', 'gather-rule', 'ime-log-pattern']).optional().default('all')
.describe('Filter by document type. Default: search all types.'),
minScore: z.coerce.number().min(0).max(1).optional().default(0.3)
.describe('Minimum similarity score threshold (0-1, default 0.3). Lower = more results, higher = stricter matching.'),
},
READ_ONLY,
async ({ query, topK, type, minScore }) => {
if (!knowledgeBase || knowledgeBase.size === 0) {
return {
isError: true,
content: [{
type: 'text' as const,
text: JSON.stringify({ error: 'Knowledge base not initialized. The server may still be loading.' }),
}],
};
}
const fetchK = type === 'all' ? topK : topK * 3;
let results = await knowledgeBase.search(query, { topK: fetchK, minScore });
if (type !== 'all') {
results = results.filter((r) => r.metadata.type === type);
}
results = results.slice(0, topK);
const formatted = results.map((r) => ({
id: r.id,
score: Math.round(r.score * 1000) / 1000,
type: r.metadata.type,
title: r.metadata.title ?? r.metadata.description ?? r.id,
content: r.text,
metadata: r.metadata,
}));
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
query,
searchBackend: knowledgeBase.name,
resultCount: formatted.length,
results: formatted,
}, null, 2),
}],
};
}
);
// Tool 23: deep_search_events
server.tool(
'deep_search_events',
'TIER 3 — DEEP HYBRID SEARCH (thorough, use when accuracy is critical). ' +
'Combines semantic search with a complementary keyword cross-check to ensure nothing is missed. ' +
'First runs semantic search (same as search_events_semantic), then scans ALL events — including those ' +
'with short or missing messages — for keyword matches in eventType, source, severity, message, and data fields. ' +
'Results are merged, deduplicated, and tagged with their discovery method (semantic, keyword, or both). ' +
'Use this when: (1) a previous semantic search may have missed events, (2) you need high confidence in completeness, ' +
'or (3) the query involves specific technical terms that benefit from exact keyword matching alongside semantic meaning. ' +
'Slower than search_events_semantic due to the double-pass approach — only use when accuracy matters more than speed. ' +
'Provide sessionId to search within one session, or omit to search across recent failed sessions. ' +
'Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant.',
{
query: z.string().describe('Natural language description of what to find (e.g. "app download stuck", "certificate error", "disk space low")'),
sessionId: z.string().optional().describe('Search within a specific session. If omitted, searches across recent failed sessions.'),
tenantId: z.string().optional().describe('Tenant ID. Required for non-Global Admin users; Global Admin can omit to search across tenants.'),
topK: z.coerce.number().min(1).max(50).optional().default(15)
.describe('Max results to return (1-50, default 15). Higher default than search_events_semantic for thoroughness.'),
minScore: z.coerce.number().min(0).max(1).optional().default(0.3)
.describe('Min semantic similarity score (0-1, default 0.3). Slightly lower than search_events_semantic to catch borderline matches.'),
keywords: z.array(z.string()).optional()
.describe('Additional exact keywords for the raw cross-check pass. Auto-extracted from query if omitted.'),
},
READ_ONLY,
async ({ query, sessionId, tenantId, topK, minScore, keywords }) => {
const { events, sessionIds } = await fetchSessionEvents(sessionId, tenantId);
if (events.length === 0) {
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
query, resultCount: 0, semanticMatches: 0, keywordMatches: 0,
results: [], note: 'No events found.',
}),
}],
};
}
// ── Pass 1: Semantic search (events with meaningful messages) ──
const semanticCandidates: EventEntry[] = [];
const candidateOriginalIndices: number[] = [];
for (let i = 0; i < events.length; i++) {
if (events[i].message && events[i].message!.length > 5) {
semanticCandidates.push(events[i]);
candidateOriginalIndices.push(i);
}
}
const semanticMap = new Map<number, { score: number }>();
let searchBackend = 'none';
if (semanticCandidates.length > 0) {
const docs = semanticCandidates.map((e, i) => {
const parts = [e.message];
if (e.eventType) parts.push(`Event: ${e.eventType}`);
if (e.severity) parts.push(`Severity: ${e.severity}`);
if (e.source) parts.push(`Source: ${e.source}`);
return { id: `event-${i}`, text: parts.join(' | '), metadata: { index: i } as Record<string, unknown> };
});
const provider = await createSearchProvider();
searchBackend = provider.name;
await provider.index(docs);
const searchResults = await provider.search(query, { topK, minScore });
for (const r of searchResults) {
const candidateIdx = r.metadata.index as number;
const originalIdx = candidateOriginalIndices[candidateIdx];
semanticMap.set(originalIdx, { score: Math.round(r.score * 1000) / 1000 });
}
}
// ── Pass 2: Keyword cross-check (ALL events, including short/empty messages) ──
const queryKeywords = keywords ?? extractKeywords(query);
const keywordMap = new Map<number, { matchedKeywords: string[] }>();
for (let i = 0; i < events.length; i++) {
const e = events[i];
const searchText = [
e.message ?? '', e.eventType ?? '', e.source ?? '',
e.severity ?? '', e.phase ?? '',
e.data ? JSON.stringify(e.data) : '',
].join(' ').toLowerCase();
const matched = queryKeywords.filter((kw) => searchText.includes(kw.toLowerCase()));
if (matched.length > 0) {
keywordMap.set(i, { matchedKeywords: matched });
}
}
// ── Merge & deduplicate ──
const allIndices = new Set([...semanticMap.keys(), ...keywordMap.keys()]);
type DiscoveryMethod = 'both' | 'semantic' | 'keyword';
const merged = Array.from(allIndices).map((idx) => {
const e = events[idx];
const semantic = semanticMap.get(idx);
const keyword = keywordMap.get(idx);
const discoveryMethod: DiscoveryMethod = semantic && keyword ? 'both' : semantic ? 'semantic' : 'keyword';
return {
score: semantic?.score ?? 0,
discoveryMethod,
matchedKeywords: keyword?.matchedKeywords ?? [],
sessionId: e._sessionId ?? sessionId,
eventType: e.eventType,
severity: e.severity,
source: e.source,
phase: e.phase,
timestamp: e.timestamp,
message: e.message,
};
});
const methodRank: Record<DiscoveryMethod, number> = { both: 0, semantic: 1, keyword: 2 };
merged.sort((a, b) => {
const rankDiff = methodRank[a.discoveryMethod] - methodRank[b.discoveryMethod];
if (rankDiff !== 0) return rankDiff;
return b.score - a.score;
});
const results = merged.slice(0, topK);
return {
content: [{
type: 'text' as const,
text: JSON.stringify({
query,
searchBackend,
sessionsSearched: sessionIds,
totalEventsScanned: events.length,
semanticCandidates: semanticCandidates.length,
semanticMatches: semanticMap.size,
keywordMatches: keywordMap.size,
keywordsUsed: queryKeywords,
resultCount: results.length,
results,
}, null, 2),
}],
};
}
);
}
@@ -0,0 +1,359 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { apiFetch, buildQuery } from '../client.js';
import { READ_ONLY } from './shared.js';
// ── Session summary constants ───────────────────────────────────────────
const EXCLUDED_EVENT_TYPES = new Set([
'performance_snapshot', 'agent_metrics_snapshot',
'performance_snapshot_stopped', 'agent_metrics_snapshot_stopped',
'gather_result', 'gather_rules_collection_completed',
'software_inventory_analysis', 'security_audit',
'device_location', 'ntp_time_check', 'ime_agent_version',
]);
const KEY_EVENT_TYPES = new Set([
'phase_transition', 'esp_phase_changed', 'enrollment_type_detected',
'app_install_started', 'app_install_completed', 'app_install_failed', 'app_install_skipped',
'app_tracking_summary', 'error_detected',
'enrollment_complete', 'enrollment_failed', 'completion_check',
'desktop_arrived', 'hello_policy_detected', 'waiting_for_hello', 'hello_completion_timeout',
'agent_started', 'agent_shutdown', 'trace_event',
'script_completed', 'script_failed', 'vulnerability_report',
]);
const SEVERITY_RANK: Record<string, number> = { Trace: -1, Debug: 0, Info: 1, Warning: 2, Error: 3, Critical: 4 };
const PHASE_NAMES: Record<number, string> = {
0: 'Unknown', 1: 'Device Preparation', 2: 'Device Setup', 3: 'Account Setup',
4: 'Device ESP', 5: 'User ESP', 6: 'Complete', 7: 'Pre-Provisioning',
};
// ── Registration ────────────────────────────────────────────────────────
export function registerSessionTools(server: McpServer): void {
// Tool 1: search_sessions
server.tool(
'search_sessions',
'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. ' +
'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").',
{
tenantId: z.string().optional().describe('Tenant ID. Omit for cross-tenant search (Global Admin only).'),
status: z.enum(['InProgress', 'Succeeded', 'Failed']).optional().describe('Enrollment status filter'),
serialNumber: z.string().optional().describe('Device serial number (exact match)'),
deviceName: z.string().optional().describe('Device name (prefix match, e.g. "DESKTOP-")'),
manufacturer: z.string().optional().describe('Hardware manufacturer (e.g. "Microsoft", "Dell", "HP")'),
model: z.string().optional().describe('Hardware model (e.g. "Surface Pro 9")'),
osBuild: z.string().optional().describe('OS build number prefix (e.g. "26100")'),
enrollmentType: z.enum(['v1', 'v2']).optional().describe('Autopilot enrollment type'),
isPreProvisioned: z.boolean().optional().describe('Filter by White Glove / pre-provisioned enrollment'),
isHybridJoin: z.boolean().optional().describe('Filter by Hybrid Azure AD Join'),
geoCountry: z.string().optional().describe('Country of enrollment (2-letter ISO code, e.g. "DE", "US")'),
startedAfter: z.string().optional().describe('ISO 8601 datetime — only sessions started after this'),
startedBefore: z.string().optional().describe('ISO 8601 datetime — only sessions started before this'),
agentVersion: z.string().optional().describe('Monitor Agent version (exact match, e.g. "1.4.2")'),
imeAgentVersion: z.string().optional().describe('IME Agent version (exact match, e.g. "1.23.456.789")'),
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. ' +
'Values: exact match by default. Prefix with >=, <=, >, < for numeric ranges (e.g. ">=8"). ' +
'Booleans: use "True" or "False". Arrays: substring match in any element.'
),
limit: z.coerce.number().min(1).max(100).optional().default(50).describe('Maximum number of results (1-100, default 50)'),
},
READ_ONLY,
async (args) => {
const { deviceProperties, tenantId, ...rest } = args;
const queryParams: Record<string, string | number | boolean | undefined | null> = { ...rest };
if (tenantId) queryParams.tenantId = tenantId;
if (deviceProperties) {
for (const [key, value] of Object.entries(deviceProperties)) {
queryParams[`prop.${key}`] = value;
}
}
const basePath = tenantId ? '/api/search/sessions' : '/api/global/search/sessions';
const data = await apiFetch(`${basePath}${buildQuery(queryParams)}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// Tool 2: search_sessions_by_event
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. ' +
'Use this to answer: which devices had a failed Teams install, which sessions had an error in DeviceSetup phase.',
{
eventType: z.string().describe('Event type string — see event_types resource 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).'),
limit: z.coerce.number().min(1).max(100).optional().default(50),
},
READ_ONLY,
async (args) => {
const { tenantId, ...rest } = args;
const params: Record<string, string | number | boolean | undefined | null> = { ...rest };
if (tenantId) params.tenantId = tenantId;
const basePath = tenantId ? '/api/search/sessions-by-event' : '/api/global/search/sessions-by-event';
const data = await apiFetch(`${basePath}${buildQuery(params)}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// Tool 3: get_session
server.tool(
'get_session',
'Get full details of a single enrollment session including all device metadata. Set includeAnalysis=true to also get AI rule analysis results explaining why the session failed and remediation suggestions.',
{
sessionId: z.string().describe('Session UUID'),
tenantId: z.string().optional().describe('Tenant ID. If omitted, auto-resolved from the session (Global Admin can access any tenant).'),
includeAnalysis: z.boolean().optional().default(false).describe('Include rule analysis results (failure explanations and remediation steps)'),
},
READ_ONLY,
async ({ sessionId, tenantId, includeAnalysis }) => {
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
const sessionData = await apiFetch(`/api/sessions/${sessionId}${q}`);
let analysisData: unknown = null;
if (includeAnalysis) {
try {
analysisData = await apiFetch(`/api/sessions/${sessionId}/analysis${q}`);
} catch {
// analysis may not exist yet
}
}
return { content: [{ type: 'text' as const, text: JSON.stringify({ session: sessionData, analysis: analysisData }, null, 2) }] };
}
);
// Tool 4: get_session_events
server.tool(
'get_session_events',
'TIER 2 — RAW EVENT RETRIEVAL (fallback when semantic search misses). ' +
'Get the complete event timeline for a single session. Filter by eventType, severity, or source (app name). ' +
'Use this when search_events_semantic returns incomplete results and you need the full unfiltered event stream, ' +
'or for root cause analysis when you need every event in chronological sequence. ' +
'If you omit tenantId, the backend auto-resolves it from the session (Global Admin can access any tenant).',
{
sessionId: z.string().describe('Session UUID'),
tenantId: z.string().optional().describe('Tenant ID. If omitted, auto-resolved from the session.'),
eventType: z.string().optional().describe('Filter to only events of this type'),
severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(),
source: z.string().optional().describe('Filter by event source/app name (e.g. "MicrosoftTeams")'),
},
READ_ONLY,
async ({ sessionId, tenantId, ...filters }) => {
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
const data = await apiFetch(`/api/sessions/${sessionId}/events${q}`) as {
events?: Array<{ eventType?: string; severity?: string; source?: string }>;
count?: number;
};
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;
}
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// Tool 5: get_session_summary
server.tool(
'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. ' +
'Use this as the FIRST tool when investigating a session. ' +
'For raw unfiltered events use get_session_events. For full metadata use get_session.',
{
sessionId: z.string().describe('Session UUID'),
tenantId: z.string().optional().describe('Tenant ID. If omitted, auto-resolved from the session (Global Admin can access any tenant).'),
},
READ_ONLY,
async ({ sessionId, tenantId }) => {
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
const fetchOpts = { signal: AbortSignal.timeout(90_000) };
const [sessionData, eventsData, analysisData] = await Promise.all([
apiFetch(`/api/sessions/${sessionId}${q}`, fetchOpts) as Promise<Record<string, unknown>>,
apiFetch(`/api/sessions/${sessionId}/events${q}`, fetchOpts) as Promise<{ events?: Array<Record<string, unknown>>; count?: number }>,
apiFetch(`/api/sessions/${sessionId}/analysis${q}`, fetchOpts).catch(() => null) as Promise<Record<string, unknown> | null>,
]);
const s = (sessionData.session ?? sessionData) as Record<string, unknown>;
const overview = {
sessionId,
tenantId: s.tenantId ?? tenantId,
status: s.status,
failureReason: s.failureReason ?? null,
startedAt: s.startedAt,
completedAt: s.completedAt ?? null,
durationSeconds: s.durationSeconds ?? null,
currentPhase: PHASE_NAMES[Number(s.currentPhase)] ?? String(s.currentPhase ?? 'Unknown'),
enrollmentType: s.enrollmentType,
isPreProvisioned: s.isPreProvisioned ?? false,
isHybridJoin: s.isHybridJoin ?? false,
isUserDriven: s.isUserDriven ?? false,
device: {
name: s.deviceName,
serialNumber: s.serialNumber,
manufacturer: s.manufacturer,
model: s.model,
osBuild: s.osBuild,
osEdition: s.osEdition,
},
agent: {
version: s.agentVersion,
imeVersion: s.imeAgentVersion,
},
location: (s.geoCountry || s.geoRegion || s.geoCity)
? { country: s.geoCountry, region: s.geoRegion, city: s.geoCity }
: null,
};
const allEvents = (eventsData?.events ?? []) as Array<Record<string, unknown>>;
let errorCount = 0;
let warningCount = 0;
let appTotal = 0;
let appSucceeded = 0;
let appFailed = 0;
let appSkipped = 0;
for (const e of allEvents) {
const sev = String(e.severity ?? '');
if (sev === 'Error' || sev === 'Critical') errorCount++;
if (sev === 'Warning') warningCount++;
const et = String(e.eventType ?? '');
if (et === 'app_install_started') appTotal++;
if (et === 'app_install_completed') appSucceeded++;
if (et === 'app_install_failed') appFailed++;
if (et === 'app_install_skipped') appSkipped++;
}
let keyEvents = 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) => ({
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;
if (analysisData) {
const a = analysisData as Record<string, unknown>;
const results = (a.results ?? []) as Array<Record<string, unknown>>;
analysis = {
totalIssues: a.totalIssues ?? results.length,
criticalCount: a.criticalCount ?? 0,
highCount: a.highCount ?? 0,
warningCount: a.warningCount ?? 0,
issues: results.map((r) => ({
ruleTitle: r.ruleTitle ?? r.title,
severity: r.severity,
explanation: r.explanation,
remediation: r.remediation,
})),
};
}
const result = {
overview,
keyEvents: mappedEvents,
analysis,
stats: {
totalEvents: allEvents.length,
keyEventsShown: mappedEvents.length,
errorCount,
warningCount,
appInstalls: { total: appTotal, succeeded: appSucceeded, failed: appFailed, skipped: appSkipped },
},
};
return { content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }] };
}
);
// Tool 6: get_metrics
server.tool(
'get_metrics',
'Get aggregated enrollment metrics: failure rates, slowest/most-failing apps, session counts. ' +
'Omit tenantId for cross-tenant platform overview (Global Admin). Specify tenantId for single-tenant metrics.',
{
tenantId: z.string().optional().describe('Tenant ID. Omit for cross-tenant overview (Global Admin only).'),
days: z.coerce.number().refine(v => [7, 30, 90].includes(v), { message: 'days must be 7, 30, or 90' }).optional().default(30),
},
READ_ONLY,
async (args) => {
const { tenantId, ...rest } = args;
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 [summary, apps] = await Promise.all([
apiFetch(`${prefix}/summary${q}`).catch(() => null),
apiFetch(`${prefix}/app${q}`).catch(() => null),
]);
return { content: [{ type: 'text' as const, text: JSON.stringify({ summary, apps }, null, 2) }] };
}
);
// Tool 7: search_sessions_by_cve
server.tool(
'search_sessions_by_cve',
"Find enrollment sessions where a specific CVE was detected in the device's software inventory. " +
"Omit tenantId for cross-tenant search (Global Admin). " +
"Requires vulnerability scanning to be enabled. Use this to answer: which devices are affected by CVE-2024-XXXX, show all critical vulnerability sessions.",
{
cveId: z.string().describe('CVE identifier (e.g. "CVE-2024-21447")'),
tenantId: z.string().optional().describe('Tenant ID. Omit for cross-tenant search (Global Admin only).'),
minCvssScore: z.coerce.number().min(0).max(10).optional().describe('Minimum CVSS score filter (e.g. 7.0 for high+critical)'),
overallRisk: z.enum(['low', 'medium', 'high', 'critical']).optional(),
limit: z.coerce.number().min(1).max(100).optional().default(50),
},
READ_ONLY,
async (args) => {
const { tenantId, ...rest } = args;
const params: Record<string, string | number | boolean | undefined | null> = { ...rest };
if (tenantId) params.tenantId = tenantId;
const basePath = tenantId ? '/api/search/sessions-by-cve' : '/api/global/search/sessions-by-cve';
const data = await apiFetch(`${basePath}${buildQuery(params)}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
// Tool 8: list_blocked_devices
server.tool(
'list_blocked_devices',
'List devices currently blocked from enrolling. Blocked devices have their enrollment sessions rejected by the backend. ' +
'Omit tenantId to list blocked devices across ALL tenants (requires Global Admin).',
{
tenantId: z.string().optional().describe('Tenant ID to scope results. Omit for cross-tenant listing (Global Admin only).'),
},
READ_ONLY,
async ({ tenantId }) => {
const endpoint = tenantId
? `/api/devices/blocked${buildQuery({ tenantId } as Record<string, string | undefined>)}`
: '/api/global/devices/blocked';
const data = await apiFetch(endpoint);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
}
@@ -0,0 +1,17 @@
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
/** Read-only query tool — no side effects, idempotent, closed-world (our backend only). */
export const READ_ONLY: ToolAnnotations = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
};
/** KQL / raw log query — read-only but open-world (arbitrary KQL against App Insights). */
export const READ_ONLY_OPEN: ToolAnnotations = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
};