mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
added structured error handler
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-12
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-13
@@ -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}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user