From d824dd64fd596df52f7a7ddf0be82c5730d90fd7 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 2 Apr 2026 22:02:09 +0200 Subject: [PATCH] added structured error handler --- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../Functions/Admin/GetAuditLogsFunction.cs | 13 +- .../Admin/GetGlobalAuditLogsFunction.cs | 13 +- .../Functions/Admin/GetOpsEventsFunction.cs | 11 +- .../Metrics/MetricsSummaryFunction.cs | 10 +- .../Functions/Raw/AppInsightsQueryFunction.cs | 6 +- .../Functions/Raw/QueryRawEventsFunction.cs | 10 +- .../Functions/Raw/QueryRawSessionsFunction.cs | 10 +- .../Functions/Raw/TableQueryFunction.cs | 20 +- .../Sessions/GetSessionEventsFunction.cs | 14 +- .../Functions/Sessions/GetSessionFunction.cs | 9 +- .../Sessions/SearchSessionsByCveFunction.cs | 5 +- .../Sessions/SearchSessionsByEventFunction.cs | 5 +- .../Sessions/SearchSessionsFunction.cs | 5 +- .../Helpers/ResponseHelper.cs | 111 ++++- .../Middleware/GlobalExceptionMiddleware.cs | 70 +++ .../AutopilotMonitor.Functions/Program.cs | 3 +- .../autopilot-monitor-mcp/src/client.ts | 25 +- .../autopilot-monitor-mcp/src/tools/admin.ts | 274 ++++++------ .../src/tools/error-handler.ts | 71 ++++ .../autopilot-monitor-mcp/src/tools/search.ts | 398 +++++++++--------- .../src/tools/sessions.ts | 375 +++++++++-------- 22 files changed, 860 insertions(+), 600 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Middleware/GlobalExceptionMiddleware.cs create mode 100644 src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 114370ff..34eafc3d 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -795 +797 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs index e1d0c585..c441c4ca 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs @@ -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() - }); - - return errorResponse; + return await req.InternalServerErrorAsync(_logger, ex, "Get audit logs"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs index c1180548..5eadef58 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs @@ -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() - }); - - return errorResponse; + return await req.InternalServerErrorAsync(_logger, ex, "Get global audit logs"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs index 96e17297..f591738d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs @@ -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"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs index 375d0c7d..465901b3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs @@ -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"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs index 0fc84f17..bdf314ba 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs @@ -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"); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs index 4c921730..962f9373 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs @@ -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"); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs index e7e81706..ead58d68 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs @@ -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"); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs index 47dc9b60..12e16583 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs @@ -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 }); } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs index a2eb4aa4..15b12736 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs @@ -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() - }); - - return errorResponse; + return await req.InternalServerErrorAsync(_logger, ex, $"Get events for session '{sessionId}'"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs index 61fc921b..0c94c230 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs @@ -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}'"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByCveFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByCveFunction.cs index 6083a39b..18d719bf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByCveFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByCveFunction.cs @@ -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"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs index 2f506b04..cabd1495 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs @@ -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"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs index e0e4bf80..8885f3ca 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs @@ -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"); } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/ResponseHelper.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/ResponseHelper.cs index 4dd96fae..50dfa5b3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/ResponseHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/ResponseHelper.cs @@ -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 } /// - /// 500 Internal Server Error. Logs the exception and returns { "error": "Internal server error" }. + /// 500 Internal Server Error with structured, AI-consumable error details. + /// Returns exception type, sanitized message, correlation ID, and operation context. + /// MCP clients (identified by X-Client-Source: mcp) receive richer details + /// including recovery hints and request context to help the AI self-correct. + /// Stack traces and infrastructure secrets are never exposed. + /// + public static async Task 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 + { + ["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; + } + + /// + /// 500 Internal Server Error (simple overload for backward compatibility). + /// Delegates to the enhanced overload using logMessage as the operation name. /// public static async Task 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); + } + + /// Detect MCP clients via the X-Client-Source header set by the MCP server. + 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); + } + + /// + /// Extract a safe, human-readable error message from known exception types. + /// Unknown exceptions get a generic message that points to the correlation ID. + /// + 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.", + }; + } + + /// + /// Provide AI-targeted recovery hints based on exception type. + /// Returns null when no specific guidance is available. + /// + 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, + }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/GlobalExceptionMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/GlobalExceptionMiddleware.cs new file mode 100644 index 00000000..5e786b3c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/GlobalExceptionMiddleware.cs @@ -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; + +/// +/// 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 +/// (needs correlation ID) and before +/// (catches auth-layer exceptions too). +/// MCP clients receive richer error details via the X-Client-Source header detection. +/// +public class GlobalExceptionMiddleware : IFunctionsWorkerMiddleware +{ + private readonly ILogger _logger; + + public GlobalExceptionMiddleware(ILogger 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 + { + ["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); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 871585b5..fe65a7e7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -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(); builder.UseMiddleware(); +builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); diff --git a/src/McpServer/autopilot-monitor-mcp/src/client.ts b/src/McpServer/autopilot-monitor-mcp/src/client.ts index 60c06294..fa445919 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/client.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/client.ts @@ -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 | 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; + } catch { + this.parsed = null; + } + } +} + /** * Per-request token store using AsyncLocalStorage. * @@ -47,7 +70,7 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise { - const params: Record = { ...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 = { ...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 = { ...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 = { ...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 = {}; + 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 = {}; - 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 = { ...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 = { ...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 = { ...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 = { ...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); } } ); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts new file mode 100644 index 00000000..1badc79b --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/error-handler.ts @@ -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, + 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') }], + }; +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts index a6a72cfc..c34a9501 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts @@ -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, + }; + }); + + 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, - }; - }); - - 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(); + 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 }; + }); + + 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(); + + 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 = { 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(); - 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 }; - }); - - 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(); - - 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 = { 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), - }], - }; } ); } diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index dea3e69f..559f1c7c 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -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 = { ...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 = { ...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 = { ...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 = { ...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); - 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); + 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); - 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); + 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); - const fetchOpts = { signal: AbortSignal.timeout(90_000) }; + async (args) => { + try { + const { sessionId, tenantId } = args; + const q = buildQuery({ tenantId } as Record); + const fetchOpts = { signal: AbortSignal.timeout(90_000) }; - const [sessionData, eventsData, analysisData] = await Promise.all([ - apiFetch(`/api/sessions/${sessionId}${q}`, fetchOpts) as Promise>, - apiFetch(`/api/sessions/${sessionId}/events${q}`, fetchOpts) as Promise<{ events?: Array>; count?: number }>, - apiFetch(`/api/sessions/${sessionId}/analysis${q}`, fetchOpts).catch(() => null) as Promise | null>, - ]); + const [sessionData, eventsData, analysisData] = await Promise.all([ + apiFetch(`/api/sessions/${sessionId}${q}`, fetchOpts) as Promise>, + apiFetch(`/api/sessions/${sessionId}/events${q}`, fetchOpts) as Promise<{ events?: Array>; count?: number }>, + apiFetch(`/api/sessions/${sessionId}/analysis${q}`, fetchOpts).catch(() => null) as Promise | null>, + ]); - const s = (sessionData.session ?? sessionData) as Record; + const s = (sessionData.session ?? sessionData) as Record; - 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>; - - 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; - const results = (a.results ?? []) as Array>; - 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>; + + 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; + const results = (a.results ?? []) as Array>; + 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 = { ...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 = { ...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 = { ...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 = { ...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)}` - : '/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)}` + : '/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); + } } ); }