added structured error handler

This commit is contained in:
Oliver Kieselbach
2026-04-02 22:02:09 +02:00
parent 42d296b9af
commit d824dd64fd
22 changed files with 860 additions and 600 deletions
@@ -1 +1 @@
795
797
@@ -47,18 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting audit logs");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error",
count = 0,
logs = Array.Empty<object>()
});
return errorResponse;
return await req.InternalServerErrorAsync(_logger, ex, "Get audit logs");
}
}
}
@@ -47,18 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting global audit logs");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error",
count = 0,
logs = Array.Empty<object>()
});
return errorResponse;
return await req.InternalServerErrorAsync(_logger, ex, "Get global audit logs");
}
}
}
@@ -1,4 +1,5 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -58,15 +59,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching ops events");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error"
});
return errorResponse;
return await req.InternalServerErrorAsync(_logger, ex, "Get ops events");
}
}
}
@@ -33,10 +33,7 @@ public class MetricsSummaryFunction
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting metrics summary");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Get metrics summary");
}
}
@@ -54,10 +51,7 @@ public class MetricsSummaryFunction
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting global metrics summary");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Get global metrics summary");
}
}
}
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Azure.Identity;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -108,10 +109,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying Application Insights");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { error = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Query Application Insights");
}
}
@@ -40,10 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying raw events");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { error = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Query raw events");
}
}
@@ -67,10 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying global raw events");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { error = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Query global raw events");
}
}
@@ -40,10 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying raw sessions");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { error = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Query raw sessions");
}
}
@@ -61,10 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying global raw sessions");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { error = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Query global raw sessions");
}
}
@@ -1,7 +1,9 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using Azure;
using Azure.Data.Tables;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
@@ -45,10 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing tables");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { error = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "List tables");
}
}
@@ -125,12 +124,17 @@ namespace AutopilotMonitor.Functions.Functions.Raw
});
return response;
}
catch (RequestFailedException rfe)
{
return await req.InternalServerErrorAsync(_logger, rfe,
$"Query table '{tableName}'",
new { tableName, filter = req.Query["filter"], partitionKey = req.Query["partitionKey"] });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error querying table {TableName}", tableName);
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { error = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex,
$"Query table '{tableName}'",
new { tableName });
}
}
@@ -54,19 +54,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error getting events for session {sessionId}");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error",
sessionId = sessionId,
count = 0,
events = Array.Empty<object>()
});
return errorResponse;
return await req.InternalServerErrorAsync(_logger, ex, $"Get events for session '{sessionId}'");
}
}
}
@@ -77,14 +77,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
}
catch (Exception ex)
{
_logger.LogError(ex, $"{sessionPrefix} Error getting session");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new
{
success = false,
message = "Internal server error"
});
return errorResponse;
return await req.InternalServerErrorAsync(_logger, ex, $"Get session '{sessionId}'");
}
}
}
@@ -67,10 +67,7 @@ public class SearchSessionsByCveFunction
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching sessions by CVE");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Search sessions by CVE");
}
}
}
@@ -65,10 +65,7 @@ public class SearchSessionsByEventFunction
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching sessions by event");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Search sessions by event");
}
}
}
@@ -93,10 +93,7 @@ public class SearchSessionsFunction
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching sessions");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
return err;
return await req.InternalServerErrorAsync(_logger, ex, "Search sessions");
}
}
}
@@ -1,4 +1,6 @@
using System.Net;
using Azure;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -59,7 +61,48 @@ public static class ResponseHelper
}
/// <summary>
/// 500 Internal Server Error. Logs the exception and returns <c>{ "error": "Internal server error" }</c>.
/// 500 Internal Server Error with structured, AI-consumable error details.
/// Returns exception type, sanitized message, correlation ID, and operation context.
/// MCP clients (identified by <c>X-Client-Source: mcp</c>) receive richer details
/// including recovery hints and request context to help the AI self-correct.
/// Stack traces and infrastructure secrets are never exposed.
/// </summary>
public static async Task<HttpResponseData> InternalServerErrorAsync(
this HttpRequestData req,
ILogger logger,
Exception ex,
string operation,
object? context = null)
{
var correlationId = req.FunctionContext.GetCorrelationId();
logger.LogError(ex, "{Operation} failed [CorrelationId={CorrelationId}]", operation, correlationId);
var isMcpClient = IsMcpRequest(req);
var errorBody = new Dictionary<string, object?>
{
["error"] = SanitizeErrorMessage(ex, operation),
["correlationId"] = correlationId,
["exceptionType"] = ex.GetType().Name,
};
// MCP clients get richer details to help the AI self-correct
if (isMcpClient)
{
errorBody["operation"] = operation;
if (context != null) errorBody["context"] = context;
var hint = GetRecoveryHint(ex);
if (hint != null) errorBody["hint"] = hint;
}
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(errorBody);
return response;
}
/// <summary>
/// 500 Internal Server Error (simple overload for backward compatibility).
/// Delegates to the enhanced overload using logMessage as the operation name.
/// </summary>
public static async Task<HttpResponseData> InternalServerErrorAsync(
this HttpRequestData req,
@@ -67,9 +110,67 @@ public static class ResponseHelper
Exception ex,
string logMessage = "Unhandled error")
{
logger.LogError(ex, logMessage);
var response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { error = "Internal server error" });
return response;
return await InternalServerErrorAsync(req, logger, ex, operation: logMessage);
}
/// <summary>Detect MCP clients via the X-Client-Source header set by the MCP server.</summary>
internal static bool IsMcpRequest(HttpRequestData req)
{
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext == null) return false;
return string.Equals(
httpContext.Request.Headers["X-Client-Source"].FirstOrDefault(),
"mcp", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Extract a safe, human-readable error message from known exception types.
/// Unknown exceptions get a generic message that points to the correlation ID.
/// </summary>
internal static string SanitizeErrorMessage(Exception ex, string operation)
{
return ex switch
{
RequestFailedException rfe =>
$"{operation}: {rfe.ErrorCode ?? $"Azure error (HTTP {rfe.Status})"} — {rfe.Message}",
ArgumentException ae =>
$"{operation}: Invalid argument — {ae.Message}",
InvalidOperationException ioe =>
$"{operation}: {ioe.Message}",
TimeoutException =>
$"{operation}: The operation timed out",
TaskCanceledException =>
$"{operation}: The operation timed out or was cancelled",
HttpRequestException hre =>
$"{operation}: External service call failed ({hre.StatusCode})",
FormatException fe =>
$"{operation}: Invalid format — {fe.Message}",
_ =>
$"{operation} failed. Use correlationId to investigate in backend logs.",
};
}
/// <summary>
/// Provide AI-targeted recovery hints based on exception type.
/// Returns null when no specific guidance is available.
/// </summary>
internal static string? GetRecoveryHint(Exception ex)
{
return ex switch
{
RequestFailedException { ErrorCode: "InvalidInput" or "BadRequest" } =>
"The OData filter expression may be malformed. Check syntax: string values need single quotes, property names are case-sensitive. Example: \"Status eq 'Failed'\".",
RequestFailedException { Status: 404 } =>
"The requested resource was not found. Verify the table name, partition key, or entity exists.",
RequestFailedException { Status: 409 } =>
"Conflict — the entity was modified concurrently. Retry the operation.",
RequestFailedException { Status: 429 } =>
"Rate limited by Azure Storage. Wait a moment and retry with a smaller query.",
TimeoutException or TaskCanceledException =>
"The backend timed out. Try reducing the query scope (fewer results, narrower time range, more specific filters).",
ArgumentException =>
"One or more parameters are invalid. Check parameter types and required fields.",
_ => null,
};
}
}
@@ -0,0 +1,70 @@
using System.Net;
using Azure;
using AutopilotMonitor.Functions.Helpers;
using Microsoft.AspNetCore.Http;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Middleware;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Middleware;
/// <summary>
/// Safety-net middleware that catches any exception escaping past function-level catch blocks
/// and returns a structured error response instead of a raw 500. Placed after
/// <see cref="CorrelationIdMiddleware"/> (needs correlation ID) and before
/// <see cref="AuthenticationMiddleware"/> (catches auth-layer exceptions too).
/// MCP clients receive richer error details via the X-Client-Source header detection.
/// </summary>
public class GlobalExceptionMiddleware : IFunctionsWorkerMiddleware
{
private readonly ILogger<GlobalExceptionMiddleware> _logger;
public GlobalExceptionMiddleware(ILogger<GlobalExceptionMiddleware> logger)
{
_logger = logger;
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
try
{
await next(context);
}
catch (Exception ex)
{
var httpContext = context.GetHttpContext();
if (httpContext == null)
throw; // Non-HTTP triggers: let it propagate to the Functions runtime
if (httpContext.Response.HasStarted)
throw; // Response already streaming: can't write a JSON body
var correlationId = context.GetCorrelationId();
var functionName = context.FunctionDefinition.Name;
_logger.LogError(ex, "Unhandled exception in {Function} [CorrelationId={CorrelationId}]",
functionName, correlationId);
var isMcp = string.Equals(
httpContext.Request.Headers["X-Client-Source"].FirstOrDefault(),
"mcp", StringComparison.OrdinalIgnoreCase);
var errorBody = new Dictionary<string, object?>
{
["error"] = ResponseHelper.SanitizeErrorMessage(ex, functionName),
["correlationId"] = correlationId,
["exceptionType"] = ex.GetType().Name,
};
if (isMcp)
{
errorBody["operation"] = functionName;
var hint = ResponseHelper.GetRecoveryHint(ex);
if (hint != null) errorBody["hint"] = hint;
}
httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(errorBody);
}
}
}
@@ -21,9 +21,10 @@ var builder = FunctionsApplication.CreateBuilder(args);
builder.ConfigureFunctionsWebApplication();
// Register middleware pipeline (Azure Functions .NET 8 isolated worker pattern)
// Order matters: request telemetry (wraps all) → correlation ID → JWT authentication (401) → policy enforcement (403)
// Order matters: request telemetry (wraps all) → correlation ID → global exception handler → JWT authentication (401) → policy enforcement (403)
builder.UseMiddleware<RequestTelemetryMiddleware>();
builder.UseMiddleware<CorrelationIdMiddleware>();
builder.UseMiddleware<GlobalExceptionMiddleware>();
builder.UseMiddleware<AuthenticationMiddleware>();
builder.UseMiddleware<PolicyEnforcementMiddleware>();
builder.UseMiddleware<UserRateLimitMiddleware>();
@@ -5,6 +5,29 @@ const BASE_URL = process.env.AUTOPILOT_API_URL ?? 'https://autopilotmonitor-api.
/** Default timeout for backend API requests (30 seconds) */
const API_TIMEOUT_MS = 30_000;
/**
* Structured error thrown when the backend API returns a non-2xx response.
* Preserves the HTTP status, raw body, and parsed JSON (when available) so
* downstream error handlers can format rich, AI-consumable messages.
*/
export class ApiError extends Error {
readonly status: number;
readonly body: string;
readonly parsed: Record<string, unknown> | null;
constructor(status: number, body: string) {
super(`API error ${status}: ${body}`);
this.name = 'ApiError';
this.status = status;
this.body = body;
try {
this.parsed = JSON.parse(body) as Record<string, unknown>;
} catch {
this.parsed = null;
}
}
}
/**
* Per-request token store using AsyncLocalStorage.
*
@@ -47,7 +70,7 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise<unknow
const res = await fetch(url, { ...options, headers, signal });
if (!res.ok) {
const text = await res.text();
throw new Error(`API error ${res.status}: ${text}`);
throw new ApiError(res.status, text);
}
return res.json();
}
@@ -2,6 +2,7 @@ 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';
import { toolError } from './error-handler.js';
export function registerAdminTools(server: McpServer): void {
// Tool 11: get_api_usage
@@ -31,11 +32,7 @@ export function registerAdminTools(server: McpServer): void {
}
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;
return toolError('get_api_usage', args, error);
}
}
);
@@ -53,12 +50,17 @@ export function registerAdminTools(server: McpServer): void {
.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) }] };
async (args) => {
try {
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 data = await apiFetch(`${prefix}/geographic${buildQuery(params)}`);
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (error: unknown) {
return toolError('get_geographic_metrics', args, error);
}
}
);
@@ -77,17 +79,22 @@ export function registerAdminTools(server: McpServer): void {
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';
async (args) => {
try {
const { tenantId, country, region, city, ...rest } = args;
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) }] };
} catch (error: unknown) {
return toolError('get_geographic_sessions', args, error);
}
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) }] };
}
);
@@ -99,68 +106,72 @@ export function registerAdminTools(server: McpServer): void {
'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' }) }] };
async (args) => {
try {
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) }] };
} catch (error: unknown) {
return toolError('get_platform_metrics', args, error);
}
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) }] };
}
);
@@ -173,13 +184,18 @@ export function registerAdminTools(server: McpServer): void {
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 })}`);
async (args) => {
try {
const { tenantId } = args;
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) }] };
} catch (error: unknown) {
return toolError('get_usage_metrics', args, error);
}
const data = await apiFetch('/api/global/metrics/usage');
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
@@ -192,12 +208,17 @@ export function registerAdminTools(server: McpServer): void {
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) }] };
async (args) => {
try {
const { tenantId } = args;
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) }] };
} catch (error: unknown) {
return toolError('get_audit_logs', args, error);
}
}
);
@@ -220,11 +241,7 @@ export function registerAdminTools(server: McpServer): void {
const data = await apiFetch(`/api/global/ops-events${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;
return toolError('get_ops_events', args, error);
}
}
);
@@ -236,9 +253,13 @@ export function registerAdminTools(server: McpServer): void {
'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) }] };
async (args) => {
try {
const data = await apiFetch('/api/global/session-reports');
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
} catch (error: unknown) {
return toolError('list_session_reports', args, error);
}
}
);
@@ -264,10 +285,14 @@ export function registerAdminTools(server: McpServer): void {
},
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) }] };
try {
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) }] };
} catch (error: unknown) {
return toolError('query_raw_events', args, error);
}
}
);
@@ -288,12 +313,16 @@ export function registerAdminTools(server: McpServer): void {
},
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) }] };
try {
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) }] };
} catch (error: unknown) {
return toolError('query_raw_sessions', args, error);
}
}
);
@@ -309,16 +338,12 @@ export function registerAdminTools(server: McpServer): void {
'List all available Azure Table Storage tables that can be queried via query_table. Global Admin only.',
{},
READ_ONLY,
async () => {
async (args) => {
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;
return toolError('list_tables', args, error);
}
}
);
@@ -342,11 +367,7 @@ export function registerAdminTools(server: McpServer): void {
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;
return toolError('query_table', args, error);
}
}
);
@@ -369,14 +390,7 @@ export function registerAdminTools(server: McpServer): void {
});
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;
return toolError('query_backend_logs', args, error);
}
}
);
@@ -0,0 +1,71 @@
import { ApiError } from '../client.js';
interface ToolErrorResult {
[x: string]: unknown;
isError: true;
content: Array<{ type: 'text'; text: string }>;
}
/**
* Format any error into an MCP-compliant `{ isError: true }` response with
* structured, AI-consumable details. Never throws — always returns a result
* the SDK can send back to the model.
*
* Handles:
* - Structured backend errors (ApiError with parsed JSON body)
* - Authentication errors
* - Timeouts (AbortError / timeout signals)
* - Legacy unstructured errors (fallback formatting)
*/
export function toolError(
toolName: string,
args: Record<string, unknown>,
error: unknown,
): ToolErrorResult {
const parts: string[] = [];
if (error instanceof ApiError && error.parsed) {
// Structured backend error — extract rich details
const p = error.parsed;
parts.push(`**Error in ${toolName}**: ${p.error ?? error.message}`);
if (p.hint) parts.push(`**Suggestion**: ${p.hint}`);
if (p.correlationId) parts.push(`**Correlation ID**: ${p.correlationId}`);
if (p.exceptionType) parts.push(`**Exception type**: ${p.exceptionType}`);
} else if (error instanceof ApiError) {
// API error but non-JSON body
if (error.status === 403) {
parts.push(`**Access denied in ${toolName}**: This operation requires higher permissions (Global Admin or Tenant Admin).`);
} else if (error.status === 404) {
parts.push(`**Not found in ${toolName}**: The requested resource does not exist. Verify IDs, table names, or filters.`);
} else if (error.status === 429) {
parts.push(`**Rate limited in ${toolName}**: Too many requests. Wait a moment and retry.`);
} else {
parts.push(`**Error in ${toolName}** (HTTP ${error.status}): ${error.body || 'No response body'}`);
}
} else {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('No authentication token')) {
parts.push(`**Authentication error in ${toolName}**: ${message}`);
parts.push('**Suggestion**: The MCP session may have expired. Re-authenticate.');
} else if (message.includes('TimeoutError') || message.includes('AbortError') || message.includes('timed out')) {
parts.push(`**Timeout in ${toolName}**: The backend did not respond in time.`);
parts.push('**Suggestion**: Try narrowing the query (smaller date range, fewer results, more specific filters).');
} else {
parts.push(`**Error in ${toolName}**: ${message}`);
}
}
// Include parameter summary so the AI can see what it sent
const argsSummary = Object.entries(args)
.filter(([, v]) => v != null && v !== undefined)
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
.join('\n');
if (argsSummary) {
parts.push(`**Parameters used**:\n${argsSummary}`);
}
return {
isError: true,
content: [{ type: 'text' as const, text: parts.join('\n\n') }],
};
}
@@ -4,6 +4,7 @@ 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';
import { toolError } from './error-handler.js';
// ── Helpers ─────────────────────────────────────────────────────────────
@@ -88,64 +89,69 @@ export function registerSearchTools(server: McpServer, knowledgeBase?: SearchPro
.describe('Minimum similarity score (0-1, default 0.35)'),
},
READ_ONLY,
async ({ query, sessionId, tenantId, topK, minScore }) => {
const { events, sessionIds } = await fetchSessionEvents(sessionId, tenantId);
async (args) => {
try {
const { query, sessionId, tenantId, topK, minScore } = args;
const { events, sessionIds } = await fetchSessionEvents(sessionId, tenantId);
const candidates = events.filter((e) => e.message && e.message.length > 5);
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,
};
});
if (candidates.length === 0) {
return {
content: [{
type: 'text' as const,
text: JSON.stringify({ query, resultCount: 0, results: [], note: 'No events with messages found.' }),
text: JSON.stringify({
query,
searchBackend: provider.name,
sessionsSearched: sessionIds,
eventsAnalyzed: candidates.length,
resultCount: results.length,
results,
}, null, 2),
}],
};
} catch (error: unknown) {
return toolError('search_events_semantic', args, error);
}
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),
}],
};
}
);
@@ -165,46 +171,51 @@ export function registerSearchTools(server: McpServer, knowledgeBase?: SearchPro
.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) {
async (args) => {
try {
const { query, topK, type, minScore } = args;
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 {
isError: true,
content: [{
type: 'text' as const,
text: JSON.stringify({ error: 'Knowledge base not initialized. The server may still be loading.' }),
text: JSON.stringify({
query,
searchBackend: knowledgeBase.name,
resultCount: formatted.length,
results: formatted,
}, null, 2),
}],
};
} catch (error: unknown) {
return toolError('search_knowledge', args, error);
}
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),
}],
};
}
);
@@ -233,121 +244,126 @@ export function registerSearchTools(server: McpServer, knowledgeBase?: SearchPro
.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);
async (args) => {
try {
const { query, sessionId, tenantId, topK, minScore, keywords } = args;
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);
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.',
}),
query,
searchBackend,
sessionsSearched: sessionIds,
totalEventsScanned: events.length,
semanticCandidates: semanticCandidates.length,
semanticMatches: semanticMap.size,
keywordMatches: keywordMap.size,
keywordsUsed: queryKeywords,
resultCount: results.length,
results,
}, null, 2),
}],
};
} catch (error: unknown) {
return toolError('deep_search_events', args, error);
}
// ── 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),
}],
};
}
);
}
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { apiFetch, buildQuery } from '../client.js';
import { READ_ONLY } from './shared.js';
import { toolError } from './error-handler.js';
// ── Session summary constants ───────────────────────────────────────────
@@ -65,17 +66,21 @@ export function registerSessionTools(server: McpServer): void {
},
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;
try {
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) }] };
} catch (error: unknown) {
return toolError('search_sessions', args, error);
}
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) }] };
}
);
@@ -93,12 +98,16 @@ export function registerSessionTools(server: McpServer): void {
},
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) }] };
try {
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) }] };
} catch (error: unknown) {
return toolError('search_sessions_by_event', args, error);
}
}
);
@@ -112,18 +121,23 @@ export function registerSessionTools(server: McpServer): void {
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
async (args) => {
try {
const { sessionId, tenantId, includeAnalysis } = args;
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) }] };
} catch (error: unknown) {
return toolError('get_session', args, error);
}
return { content: [{ type: 'text' as const, text: JSON.stringify({ session: sessionData, analysis: analysisData }, null, 2) }] };
}
);
@@ -143,22 +157,27 @@ export function registerSessionTools(server: McpServer): void {
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;
async (args) => {
try {
const { sessionId, tenantId, ...filters } = args;
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) }] };
} catch (error: unknown) {
return toolError('get_session_events', args, error);
}
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
);
@@ -176,118 +195,123 @@ export function registerSessionTools(server: McpServer): void {
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) };
async (args) => {
try {
const { sessionId, tenantId } = args;
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 [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 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 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) }] };
} catch (error: unknown) {
return toolError('get_session_summary', args, error);
}
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) }] };
}
);
@@ -302,16 +326,20 @@ export function registerSessionTools(server: McpServer): void {
},
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) }] };
try {
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) }] };
} catch (error: unknown) {
return toolError('get_metrics', args, error);
}
}
);
@@ -330,12 +358,16 @@ export function registerSessionTools(server: McpServer): void {
},
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) }] };
try {
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) }] };
} catch (error: unknown) {
return toolError('search_sessions_by_cve', args, error);
}
}
);
@@ -348,12 +380,17 @@ export function registerSessionTools(server: McpServer): void {
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) }] };
async (args) => {
try {
const { tenantId } = args;
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) }] };
} catch (error: unknown) {
return toolError('list_blocked_devices', args, error);
}
}
);
}