diff --git a/src/McpServer/autopilot-monitor-mcp/package.json b/src/McpServer/autopilot-monitor-mcp/package.json index fdad6a4b..d0bfe209 100644 --- a/src/McpServer/autopilot-monitor-mcp/package.json +++ b/src/McpServer/autopilot-monitor-mcp/package.json @@ -1,6 +1,6 @@ { "name": "autopilot-monitor-mcp", - "version": "1.3.0", + "version": "1.4.0", "description": "MCP server for Autopilot-Monitor — enables AI agents to query enrollment sessions, device properties, events and vulnerabilities", "type": "module", "bin": { diff --git a/src/McpServer/autopilot-monitor-mcp/src/index.ts b/src/McpServer/autopilot-monitor-mcp/src/index.ts index e07ccaaf..5d4ad737 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/index.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/index.ts @@ -1,7 +1,7 @@ import { resolve, dirname } from 'node:path'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import express from 'express'; +import express, { type ErrorRequestHandler } from 'express'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { registerTools } from './tools.js'; @@ -175,6 +175,44 @@ app.all('/mcp', async (req, res) => { } }); +// Body-parse error handler — registered after the routes so Express routes the +// parser errors (express.json throws BEFORE the route handler runs) here rather +// than to its built-in handler, which emits an HTML error page. For the +// JSON-RPC /mcp endpoint that HTML would break a spec-compliant client, so we +// answer with a JSON-RPC error envelope; other routes get a plain JSON 400/413. +const bodyErrorHandler: ErrorRequestHandler = (err, req, res, next) => { + const e = err as { type?: string; status?: number; statusCode?: number } | null; + const isBodyError = + e?.type === 'entity.parse.failed' || + e?.type === 'entity.too.large' || + e?.type === 'encoding.unsupported' || + (err instanceof SyntaxError && typeof (err as { body?: unknown }).body !== 'undefined'); + if (!isBodyError || res.headersSent) { + next(err); + return; + } + const httpStatus = e?.status ?? e?.statusCode ?? 400; + if (req.path === '/mcp') { + // -32700 Parse error for malformed JSON; -32600 Invalid Request for a body + // that exceeds the size limit (valid framing, refused). id is null — the + // request id is unknowable from a body that never parsed. + const tooLarge = e?.type === 'entity.too.large'; + res.status(httpStatus).json({ + jsonrpc: '2.0', + error: { + code: tooLarge ? -32600 : -32700, + message: tooLarge + ? 'Invalid Request: request body exceeds the size limit.' + : 'Parse error: request body is not valid JSON.', + }, + id: null, + }); + } else { + res.status(httpStatus).json({ error: 'Invalid request body.' }); + } +}; +app.use(bodyErrorHandler); + const server = app.listen(PORT, '0.0.0.0', () => { console.error(`Autopilot-Monitor MCP Server running on port ${PORT}`); }); diff --git a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts index 569b734b..16c01817 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/prompts.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/prompts.ts @@ -15,11 +15,15 @@ import { z } from 'zod'; * in the argument text for the model. */ export function registerPrompts(server: McpServer, ga: boolean): void { - server.prompt( + server.registerPrompt( 'investigate-failed-session', - 'Guided root-cause investigation of a single enrollment session. Seeds the ' + - 'summary-first → drill-down workflow and asks for a structured verdict.', - { sessionId: z.string().describe('Session UUID to investigate') }, + { + title: 'Investigate Failed Session', + description: + 'Guided root-cause investigation of a single enrollment session. Seeds the ' + + 'summary-first → drill-down workflow and asks for a structured verdict.', + argsSchema: { sessionId: z.string().describe('Session UUID to investigate') }, + }, ({ sessionId }) => ({ messages: [ { @@ -44,16 +48,20 @@ export function registerPrompts(server: McpServer, ga: boolean): void { }), ); - server.prompt( + server.registerPrompt( 'cve-exposure-audit', - 'Fleet exposure audit for a specific CVE: which devices/sessions are affected, ' + - 'how severe, and what to do about it.', { - cveId: z.string().describe('CVE identifier, e.g. "CVE-2024-21447"'), - tenantId: z - .string() - .optional() - .describe(ga ? 'Optional tenant ID to scope the audit. Omit for a cross-tenant audit (Global Admin).' : 'Optional tenant ID. Defaults to your tenant.'), + title: 'CVE Exposure Audit', + description: + 'Fleet exposure audit for a specific CVE: which devices/sessions are affected, ' + + 'how severe, and what to do about it.', + argsSchema: { + cveId: z.string().describe('CVE identifier, e.g. "CVE-2024-21447"'), + tenantId: z + .string() + .optional() + .describe(ga ? 'Optional tenant ID to scope the audit. Omit for a cross-tenant audit (Global Admin).' : 'Optional tenant ID. Defaults to your tenant.'), + }, }, ({ cveId, tenantId }) => ({ messages: [ @@ -81,11 +89,15 @@ export function registerPrompts(server: McpServer, ga: boolean): void { // Global Admin only — relies on get_platform_metrics (a GA-only tool that is // not registered for normal users). Hidden from non-GA so it never references // a tool they cannot see. - if (ga) server.prompt( + if (ga) server.registerPrompt( 'compare-agent-versions', - 'Compare enrollment success rate and agent resource usage across Monitor Agent ' + - 'versions over a time window — useful for validating a rollout.', - { days: z.string().optional().describe('Time window in days (1-365). Defaults to 30 if omitted.') }, + { + title: 'Compare Agent Versions', + description: + 'Compare enrollment success rate and agent resource usage across Monitor Agent ' + + 'versions over a time window — useful for validating a rollout.', + argsSchema: { days: z.string().optional().describe('Time window in days (1-365). Defaults to 30 if omitted.') }, + }, ({ days }) => { const window = days ?? '30'; return { diff --git a/src/McpServer/autopilot-monitor-mcp/src/resources.ts b/src/McpServer/autopilot-monitor-mcp/src/resources.ts index aff847e0..75229025 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resources.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resources.ts @@ -8,10 +8,11 @@ import { EVENT_TYPES_CATALOG, DEVICE_PROPERTIES_CATALOG } from './resource-catal * data via a regular tool call. */ export function registerResources(server: McpServer): void { - server.resource( + server.registerResource( 'event_types', 'autopilot://event-types', { + title: 'Event Types Catalog', mimeType: 'application/json', description: 'Catalog of all known enrollment event type strings. Consult this before calling search_sessions_by_event to know valid eventType values.', }, @@ -26,10 +27,11 @@ export function registerResources(server: McpServer): void { }) ); - server.resource( + server.registerResource( 'device_properties', 'autopilot://device-properties', { + title: 'Device Properties Catalog', mimeType: 'application/json', description: 'Catalog of known device property keys for the deviceProperties filter in search_sessions. ' + diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 5058131d..1429314b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -42,22 +42,26 @@ export function extractTenantList(data: unknown): Record[] { export function registerAdminTools(server: McpServer, ga: boolean): void { // Tool 11: get_api_usage — Global Admin only; not registered for normal users - // (the `if (ga)` guards the whole single server.tool(...) statement). - if (ga) server.tool( + // (the `if (ga)` guards the whole single server.registerTool(...) statement). + if (ga) server.registerTool( 'get_api_usage', - 'Get API/MCP usage statistics. Shows request counts per endpoint per day. ' + - 'Use to monitor platform usage, identify heavy users, or debug rate limiting. ' + - 'Use userId for a specific user, daily for aggregated summaries, or neither for global per-record breakdown. ' + - 'The daily and global breakdowns are Global Admin only; the per-user (userId) view also works for a ' + - 'Tenant Admin querying a user within their own tenant.', { - userId: z.string().optional().describe('Specific user object ID to query usage for'), - tenantId: z.string().optional().describe('Filter usage by tenant ID'), - dateFrom: z.string().optional().describe('Start date (YYYY-MM-DD)'), - dateTo: z.string().optional().describe('End date (YYYY-MM-DD)'), - daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'), + title: 'API Usage', + description: + 'Get API/MCP usage statistics. Shows request counts per endpoint per day. ' + + 'Use to monitor platform usage, identify heavy users, or debug rate limiting. ' + + 'Use userId for a specific user, daily for aggregated summaries, or neither for global per-record breakdown. ' + + 'The daily and global breakdowns are Global Admin only; the per-user (userId) view also works for a ' + + 'Tenant Admin querying a user within their own tenant.', + inputSchema: { + userId: z.string().optional().describe('Specific user object ID to query usage for'), + tenantId: z.string().optional().describe('Filter usage by tenant ID'), + dateFrom: z.string().optional().describe('Start date (YYYY-MM-DD)'), + dateTo: z.string().optional().describe('End date (YYYY-MM-DD)'), + daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_api_usage', async () => { try { let data: unknown; @@ -77,21 +81,25 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 12: get_geographic_metrics - server.tool( + server.registerTool( 'get_geographic_metrics', - 'Get geographic distribution of enrollments — where devices are enrolling from, with performance comparisons. ' + - 'Shows per-location: session counts, success rates, avg/median/p95 duration, throughput, and outlier detection. ' + - (ga ? 'Omit tenantId for cross-tenant view (Global Admin). ' : '') + - 'Use get_geographic_sessions to drill into a specific location. ' + - 'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).', { - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant view (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - days: z.coerce.number().int().min(1).max(365).optional().default(30) - .describe('Time range in days (1-365). Defaults to 30.'), - groupBy: z.enum(['country', 'region', 'city']).optional().default('city') - .describe('Geographic grouping level (default: "city")'), + title: 'Geographic Metrics', + description: + 'Get geographic distribution of enrollments — where devices are enrolling from, with performance comparisons. ' + + 'Shows per-location: session counts, success rates, avg/median/p95 duration, throughput, and outlier detection. ' + + (ga ? 'Omit tenantId for cross-tenant view (Global Admin). ' : '') + + 'Use get_geographic_sessions to drill into a specific location. ' + + 'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).', + inputSchema: { + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant view (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + days: z.coerce.number().int().min(1).max(365).optional().default(30) + .describe('Time range in days (1-365). Defaults to 30.'), + groupBy: z.enum(['country', 'region', 'city']).optional().default('city') + .describe('Geographic grouping level (default: "city")'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_geographic_metrics', async () => { try { const { tenantId, ...rest } = args; @@ -107,29 +115,33 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 13: get_geographic_sessions - server.tool( + server.registerTool( 'get_geographic_sessions', - 'Drill into a specific geographic location and list its enrollment sessions (lean rows). ' + - 'Either provide locationKey (from get_geographic_metrics results, e.g. "Falkenstein, Saxony, DE") ' + - 'or use country/region/city filters to find sessions by location. ' + - 'A busy location can hold thousands of sessions, so results are paged: the default pageSize is 50 ' + - 'and the response carries a "nextLink" when more remain — pass that whole string back as "continuation" ' + - 'to get the next slice, and stop when nextLink is absent. (Pagination is applied to the location result ' + - 'set, so raise pageSize for fewer round-trips when you need a full sweep.)', { - locationKey: z.string().optional().describe('Location key from get_geographic_metrics (e.g. "Falkenstein, Saxony, DE"). If provided, country/region/city are ignored.'), - country: z.string().optional().describe('2-letter country code filter (e.g. "DE", "US", "CH"). Used when locationKey is not provided.'), - region: z.string().optional().describe('Region/state filter (e.g. "Saxony", "North Carolina"). Used with country.'), - city: z.string().optional().describe('City filter (e.g. "Falkenstein"). Used with country.'), - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant view (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - days: z.coerce.number().int().min(1).max(365).optional().default(30) - .describe('Time range in days (1-365). Defaults to 30.'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(50) - .describe('Sessions to return per call (1-1000, default 50). Follow nextLink for more; raise it for full sweeps.'), - continuation: z.string().optional() - .describe('Pass the whole nextLink string from the prior response to fetch the next slice.'), + title: 'Geographic Sessions', + description: + 'Drill into a specific geographic location and list its enrollment sessions (lean rows). ' + + 'Either provide locationKey (from get_geographic_metrics results, e.g. "Falkenstein, Saxony, DE") ' + + 'or use country/region/city filters to find sessions by location. ' + + 'A busy location can hold thousands of sessions, so results are paged: the default pageSize is 50 ' + + 'and the response carries a "nextLink" when more remain — pass that whole string back as "continuation" ' + + 'to get the next slice, and stop when nextLink is absent. (Pagination is applied to the location result ' + + 'set, so raise pageSize for fewer round-trips when you need a full sweep.)', + inputSchema: { + locationKey: z.string().optional().describe('Location key from get_geographic_metrics (e.g. "Falkenstein, Saxony, DE"). If provided, country/region/city are ignored.'), + country: z.string().optional().describe('2-letter country code filter (e.g. "DE", "US", "CH"). Used when locationKey is not provided.'), + region: z.string().optional().describe('Region/state filter (e.g. "Saxony", "North Carolina"). Used with country.'), + city: z.string().optional().describe('City filter (e.g. "Falkenstein"). Used with country.'), + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant view (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + days: z.coerce.number().int().min(1).max(365).optional().default(30) + .describe('Time range in days (1-365). Defaults to 30.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(50) + .describe('Sessions to return per call (1-1000, default 50). Follow nextLink for more; raise it for full sweeps.'), + continuation: z.string().optional() + .describe('Pass the whole nextLink string from the prior response to fetch the next slice.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_geographic_sessions', async () => { try { const { tenantId, country, region, city, pageSize, continuation, ...rest } = args; @@ -173,17 +185,21 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 14: get_platform_metrics — Global Admin only; not registered for normal users. - if (ga) server.tool( + if (ga) server.registerTool( 'get_platform_metrics', - 'Get aggregated platform-level agent performance metrics across recent sessions. ' + - 'Returns: avg/max/p95 CPU, memory (working set, private bytes), network (bytes up/down, latency, requests), ' + - 'top sessions by CPU/memory, and per-agent-version breakdown. Global Admin only. ' + - 'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).', { - days: z.coerce.number().int().min(1).max(365).optional().default(30) - .describe('Time window in days (1-365). Defaults to 30.'), + title: 'Platform Metrics', + description: + 'Get aggregated platform-level agent performance metrics across recent sessions. ' + + 'Returns: avg/max/p95 CPU, memory (working set, private bytes), network (bytes up/down, latency, requests), ' + + 'top sessions by CPU/memory, and per-agent-version breakdown. Global Admin only. ' + + 'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).', + inputSchema: { + days: z.coerce.number().int().min(1).max(365).optional().default(30) + .describe('Time window in days (1-365). Defaults to 30.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_platform_metrics', async () => { try { type SessionMetric = { @@ -257,18 +273,22 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 15: get_usage_metrics - server.tool( + server.registerTool( 'get_usage_metrics', - (ga - ? 'Get usage statistics. Omit tenantId for the cross-tenant platform overview (Global Admin), or pass tenantId to filter it to a single tenant. ' - : 'Get usage statistics for your tenant: session volumes, feature adoption, success rate, active users. ') + - 'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).', { - tenantId: z.string().optional().describe(ga ? 'Filter the platform-wide view to a single tenant (Global Admin only). Omit for the whole platform.' : 'Optional; ignored — usage is scoped to your tenant.'), - days: z.coerce.number().int().min(1).max(365).optional().default(30) - .describe('Time window in days (1-365). Defaults to 30. Sessions.Total / Tenants.Total reflect this window.'), + title: 'Usage Metrics', + description: + (ga + ? 'Get usage statistics. Omit tenantId for the cross-tenant platform overview (Global Admin), or pass tenantId to filter it to a single tenant. ' + : 'Get usage statistics for your tenant: session volumes, feature adoption, success rate, active users. ') + + 'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).', + inputSchema: { + tenantId: z.string().optional().describe(ga ? 'Filter the platform-wide view to a single tenant (Global Admin only). Omit for the whole platform.' : 'Optional; ignored — usage is scoped to your tenant.'), + days: z.coerce.number().int().min(1).max(365).optional().default(30) + .describe('Time window in days (1-365). Defaults to 30. Sessions.Total / Tenants.Total reflect this window.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_usage_metrics', async () => { try { const { tenantId, days } = args; @@ -288,26 +308,30 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { // The endpoint returns FULL configs (incl. secrets); extractTenantList strips // them down to TENANT_SAFE_FIELDS before anything reaches the model. // Global Admin only; not registered for normal users. - if (ga) server.tool( + if (ga) server.registerTool( 'list_tenants', - 'List onboarded tenants with their identity, plan tier, and lifecycle status (onboarded/disabled dates). ' + - 'Global Admin only. Use this to discover tenant IDs for the tenantId parameter of other tools when running ' + - 'cross-tenant investigations. Returns only non-sensitive fields — secrets (webhook URLs, SAS URLs) are stripped ' + - 'server-side. Tenants are sorted by tenantId and returned in pages (default 100). For lean ID discovery pass ' + - '`fields=tenantId,domainName` — the projection is applied server-side and is echoed in nextLink, so it carries ' + - 'across every page automatically. ' + - 'Pagination: when "nextLink" is present, more tenants are available — call again and pass that whole string ' + - 'back as "continuation". Stop when nextLink is absent.', { - fields: z.string().optional() - .describe('Comma-separated subset of safe fields to return (e.g. "tenantId,domainName" for lean ID discovery). ' + - 'tenantId is always included. Default: all safe fields. Applied server-side; unknown/secret keys are ignored.'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(100) - .describe('Page size (1-1000, default 100). Tenants are sorted by tenantId; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (pageSize, fields) round-trip correctly.'), + title: 'List Tenants', + description: + 'List onboarded tenants with their identity, plan tier, and lifecycle status (onboarded/disabled dates). ' + + 'Global Admin only. Use this to discover tenant IDs for the tenantId parameter of other tools when running ' + + 'cross-tenant investigations. Returns only non-sensitive fields — secrets (webhook URLs, SAS URLs) are stripped ' + + 'server-side. Tenants are sorted by tenantId and returned in pages (default 100). For lean ID discovery pass ' + + '`fields=tenantId,domainName` — the projection is applied server-side and is echoed in nextLink, so it carries ' + + 'across every page automatically. ' + + 'Pagination: when "nextLink" is present, more tenants are available — call again and pass that whole string ' + + 'back as "continuation". Stop when nextLink is absent.', + inputSchema: { + fields: z.string().optional() + .describe('Comma-separated subset of safe fields to return (e.g. "tenantId,domainName" for lean ID discovery). ' + + 'tenantId is always included. Default: all safe fields. Applied server-side; unknown/secret keys are ignored.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(100) + .describe('Page size (1-1000, default 100). Tenants are sorted by tenantId; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (pageSize, fields) round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('list_tenants', async () => { try { const { fields, pageSize, continuation } = args; @@ -329,25 +353,29 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 16: get_audit_logs - server.tool( + server.registerTool( 'get_audit_logs', - 'Get audit trail of administrative actions: config changes, device blocks, user management, report submissions. ' + - (ga ? 'Omit tenantId for cross-tenant audit log (Global Admin). ' : '') + - 'Forensics: dateFrom / dateTo (ISO 8601 UTC) bound the search window exactly; without either, the backend defaults to the last 30 days. ' + - 'Pagination: when "nextLink" is present in the response, more entries are available — call this tool again and pass the ' + - 'whole nextLink string (e.g. "/api/global/audit/logs?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' + - '"continuation". The tool follows it verbatim so the backend-defaulted date window round-trips correctly (otherwise ' + - 'a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', { - tenantId: z.string().optional().describe(ga ? 'Tenant ID for tenant-scoped audit log. Omit for cross-tenant view (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the audit window.'), - dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the audit window.'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). Returns this many entries per call; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo) round-trip correctly.'), + title: 'Audit Logs', + description: + 'Get audit trail of administrative actions: config changes, device blocks, user management, report submissions. ' + + (ga ? 'Omit tenantId for cross-tenant audit log (Global Admin). ' : '') + + 'Forensics: dateFrom / dateTo (ISO 8601 UTC) bound the search window exactly; without either, the backend defaults to the last 30 days. ' + + 'Pagination: when "nextLink" is present in the response, more entries are available — call this tool again and pass the ' + + 'whole nextLink string (e.g. "/api/global/audit/logs?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' + + '"continuation". The tool follows it verbatim so the backend-defaulted date window round-trips correctly (otherwise ' + + 'a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', + inputSchema: { + tenantId: z.string().optional().describe(ga ? 'Tenant ID for tenant-scoped audit log. Omit for cross-tenant view (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the audit window.'), + dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the audit window.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many entries per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo) round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_audit_logs', async () => { try { const { tenantId, dateFrom, dateTo, pageSize, continuation } = args; @@ -366,27 +394,31 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 17: get_ops_events — Global Admin only; not registered for normal users. - if (ga) server.tool( + if (ga) server.registerTool( 'get_ops_events', - 'Get operational events for platform monitoring. Shows consent flow results, maintenance runs, security blocks, ' + - 'tenant offboards, agent timeouts, and blob storage health. Global Admin only. ' + - 'Use category to narrow results (Consent, Maintenance, Security, Tenant, Agent, SLA). ' + - 'Forensics: dateFrom / dateTo (ISO 8601 UTC) bound the search window exactly; without either, the backend defaults to the last 30 days. ' + - 'Pagination: when "nextLink" is present in the response, more events are available — call this tool again and pass the ' + - 'whole nextLink string (e.g. "/api/global/ops-events?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' + - '"continuation". The tool follows it verbatim so the backend-defaulted date window round-trips correctly (otherwise ' + - 'a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', { - category: z.string().optional().describe('Filter by category: Consent, Maintenance, Security, Tenant, Agent, SLA'), - tenantId: z.string().optional().describe('Optional — filter events to a single tenant. Omit for cross-tenant view.'), - dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the window.'), - dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the window.'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). Returns this many events per call; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo) round-trip correctly.'), + title: 'Operational Events', + description: + 'Get operational events for platform monitoring. Shows consent flow results, maintenance runs, security blocks, ' + + 'tenant offboards, agent timeouts, and blob storage health. Global Admin only. ' + + 'Use category to narrow results (Consent, Maintenance, Security, Tenant, Agent, SLA). ' + + 'Forensics: dateFrom / dateTo (ISO 8601 UTC) bound the search window exactly; without either, the backend defaults to the last 30 days. ' + + 'Pagination: when "nextLink" is present in the response, more events are available — call this tool again and pass the ' + + 'whole nextLink string (e.g. "/api/global/ops-events?pageSize=...&continuation=...&dateFrom=...&dateTo=...") as ' + + '"continuation". The tool follows it verbatim so the backend-defaulted date window round-trips correctly (otherwise ' + + 'a follow-up call would compute a fresh "now" and the token fingerprint would mismatch). Stop when nextLink is absent.', + inputSchema: { + category: z.string().optional().describe('Filter by category: Consent, Maintenance, Security, Tenant, Agent, SLA'), + tenantId: z.string().optional().describe('Optional — filter events to a single tenant. Omit for cross-tenant view.'), + dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound of the window.'), + dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the window.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many events per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params (incl. resolved dateFrom/dateTo) round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_ops_events', async () => { try { const { category, tenantId, dateFrom, dateTo, pageSize, continuation } = args; @@ -404,21 +436,25 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 18: list_session_reports — Global Admin only; not registered for normal users. - if (ga) server.tool( + if (ga) server.registerTool( 'list_session_reports', - 'List session reports submitted by tenant admins. Reports contain user comments, screenshots, and agent logs for troubleshooting. ' + - 'Global Admin only — returns reports across all tenants by default; pass tenantId to filter to one tenant. ' + - 'This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical ' + - 'interactive queries; raise it (up to 1000) for bulk pulls. Pass the whole nextLink string as "continuation" so ' + - 'all backend-echoed query params round-trip correctly.', { - tenantId: z.string().optional().describe('Optional — filter to a single tenant. Omit for cross-tenant view.'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). Returns this many reports per call; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + title: 'List Session Reports', + description: + 'List session reports submitted by tenant admins. Reports contain user comments, screenshots, and agent logs for troubleshooting. ' + + 'Global Admin only — returns reports across all tenants by default; pass tenantId to filter to one tenant. ' + + 'This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical ' + + 'interactive queries; raise it (up to 1000) for bulk pulls. Pass the whole nextLink string as "continuation" so ' + + 'all backend-echoed query params round-trip correctly.', + inputSchema: { + tenantId: z.string().optional().describe('Optional — filter to a single tenant. Omit for cross-tenant view.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many reports per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('list_session_reports', async () => { try { const { tenantId, pageSize, continuation } = args; @@ -438,32 +474,36 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { // ── Raw Data Tools ──────────────────────────────────────────────────── // Tool 18: query_raw_events - server.tool( + server.registerTool( 'query_raw_events', - 'TIER 2 — RAW CROSS-SESSION EVENT QUERY (fallback for broader scope). ' + - 'Query raw enrollment events with flexible filters across sessions. ' + - (ga ? 'Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant. ' : '') + - 'Use this when search_events_semantic does not cover the time range or session scope you need, ' + - 'or when you need exact event-type filtering across many sessions. Returns raw event data. ' + - 'This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical ' + - 'interactive queries; raise it (up to 1000) for forensics-grade exact recall. For broad analysis, use ' + - 'pageSize=1000 and follow nextLink repeatedly until absent. Pass the whole nextLink string as "continuation" ' + - 'so all backend-echoed query params round-trip correctly. Note: pageSize is the index-scan cadence — a single ' + - 'indexed session can contribute multiple events, so total events per page may exceed pageSize.', { - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant search (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - sessionId: SessionIdSchema.optional().describe('Filter to a specific session'), - eventType: z.string().optional().describe('Event type filter (e.g. "app_install_failed", "error_detected")'), - severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(), - source: z.string().optional().describe('Filter by event source/app name (substring match)'), - startedAfter: z.string().optional().describe('ISO 8601 datetime — only events after this'), - startedBefore: z.string().optional().describe('ISO 8601 datetime — only events before this'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). Controls index-scan depth per call; follow nextLink for more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + title: 'Query Raw Events', + description: + 'TIER 2 — RAW CROSS-SESSION EVENT QUERY (fallback for broader scope). ' + + 'Query raw enrollment events with flexible filters across sessions. ' + + (ga ? 'Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant. ' : '') + + 'Use this when search_events_semantic does not cover the time range or session scope you need, ' + + 'or when you need exact event-type filtering across many sessions. Returns raw event data. ' + + 'This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical ' + + 'interactive queries; raise it (up to 1000) for forensics-grade exact recall. For broad analysis, use ' + + 'pageSize=1000 and follow nextLink repeatedly until absent. Pass the whole nextLink string as "continuation" ' + + 'so all backend-echoed query params round-trip correctly. Note: pageSize is the index-scan cadence — a single ' + + 'indexed session can contribute multiple events, so total events per page may exceed pageSize.', + inputSchema: { + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant search (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + sessionId: SessionIdSchema.optional().describe('Filter to a specific session'), + eventType: z.string().optional().describe('Event type filter (e.g. "app_install_failed", "error_detected")'), + severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(), + source: z.string().optional().describe('Filter by event source/app name (substring match)'), + startedAfter: z.string().optional().describe('ISO 8601 datetime — only events after this'), + startedBefore: z.string().optional().describe('ISO 8601 datetime — only events before this'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Controls index-scan depth per call; follow nextLink for more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('query_raw_events', async () => { try { const { tenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, pageSize, continuation } = args; @@ -482,34 +522,38 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 19: query_raw_sessions - server.tool( + server.registerTool( 'query_raw_sessions', - 'Query raw session data with flexible filters and field projection. ' + - (ga ? 'Specify tenantId for a specific tenant, or omit for cross-tenant access (Global Admin only). ' : '') + - 'For COUNTING / AGGREGATION queries pass `fields=sessionId,status,agentVersion,startedAt` (or similar lean subset) — ' + - 'avoids the response cap that fat raw rows trip. ' + - 'For VERSION sweeps use `agentVersionPrefix=2.0.` instead of one call per build. ' + - 'This endpoint is fully paginated — there is no truncation. Default pageSize=200; raise it (up to 1000) for bulk pulls. ' + - 'Pass the whole nextLink string as "continuation" so all backend-echoed query params round-trip correctly.', { - tenantId: z.string().optional().describe(ga ? 'Tenant ID to query. Omit for cross-tenant access (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - status: z.enum(['InProgress', 'Pending', 'Stalled', 'Succeeded', 'Failed']).optional(), - startedAfter: z.string().optional().describe('ISO 8601 datetime'), - startedBefore: z.string().optional().describe('ISO 8601 datetime'), - serialNumber: z.string().optional(), - agentVersion: z.string().optional().describe('Monitor Agent version (exact match, e.g. "2.0.626")'), - agentVersionPrefix: z.string().optional() - .describe('Monitor Agent version prefix (e.g. "2.0." matches every 2.0.x build). Mutually exclusive with agentVersion.'), - imeAgentVersion: z.string().optional().describe('IME Agent version (exact match, e.g. "1.23.456.789")'), - imeAgentVersionPrefix: z.string().optional() - .describe('IME Agent version prefix (e.g. "1.23."). Mutually exclusive with imeAgentVersion.'), - fields: z.string().optional().describe('Comma-separated fields to return (e.g. "sessionId,status,startedAt,serialNumber,durationSeconds")'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). Returns this many sessions per call; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + title: 'Query Raw Sessions', + description: + 'Query raw session data with flexible filters and field projection. ' + + (ga ? 'Specify tenantId for a specific tenant, or omit for cross-tenant access (Global Admin only). ' : '') + + 'For COUNTING / AGGREGATION queries pass `fields=sessionId,status,agentVersion,startedAt` (or similar lean subset) — ' + + 'avoids the response cap that fat raw rows trip. ' + + 'For VERSION sweeps use `agentVersionPrefix=2.0.` instead of one call per build. ' + + 'This endpoint is fully paginated — there is no truncation. Default pageSize=200; raise it (up to 1000) for bulk pulls. ' + + 'Pass the whole nextLink string as "continuation" so all backend-echoed query params round-trip correctly.', + inputSchema: { + tenantId: z.string().optional().describe(ga ? 'Tenant ID to query. Omit for cross-tenant access (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + status: z.enum(['InProgress', 'Pending', 'Stalled', 'Succeeded', 'Failed']).optional(), + startedAfter: z.string().optional().describe('ISO 8601 datetime'), + startedBefore: z.string().optional().describe('ISO 8601 datetime'), + serialNumber: z.string().optional(), + agentVersion: z.string().optional().describe('Monitor Agent version (exact match, e.g. "2.0.626")'), + agentVersionPrefix: z.string().optional() + .describe('Monitor Agent version prefix (e.g. "2.0." matches every 2.0.x build). Mutually exclusive with agentVersion.'), + imeAgentVersion: z.string().optional().describe('IME Agent version (exact match, e.g. "1.23.456.789")'), + imeAgentVersionPrefix: z.string().optional() + .describe('IME Agent version prefix (e.g. "1.23."). Mutually exclusive with imeAgentVersion.'), + fields: z.string().optional().describe('Comma-separated fields to return (e.g. "sessionId,status,startedAt,serialNumber,durationSeconds")'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many sessions per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('query_raw_sessions', async () => { try { const { tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix, @@ -536,11 +580,14 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { // needed because the backend enforces the same permission boundary. // Tool 20: list_tables — Global Admin only; not registered for normal users. - if (ga) server.tool( + if (ga) server.registerTool( 'list_tables', - 'List all available Azure Table Storage tables that can be queried via query_table. Global Admin only.', - {}, - READ_ONLY, + { + title: 'List Tables', + description: 'List all available Azure Table Storage tables that can be queried via query_table. Global Admin only.', + inputSchema: {}, + annotations: READ_ONLY, + }, async (args) => withToolTelemetry('list_tables', async () => { try { const data = await apiFetch('/api/global/raw/tables'); @@ -552,31 +599,35 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 21: query_table — Global Admin only; not registered for normal users. - if (ga) server.tool( + if (ga) server.registerTool( 'query_table', - 'Query any Azure Table Storage table directly with OData filters. Global Admin only. ' + - 'Use list_tables to see available tables. Useful for inspecting TenantConfiguration, RuleResults, or any raw data ' + - 'where no specialized tool exists. ' + - 'For COUNTING / AGGREGATION queries pass `fields=PartitionKey,RowKey,Status,AgentVersion` (or similar lean subset) ' + - 'to drop unneeded columns client-side — full TableEntity rows can be 1KB+ each and trip the response cap quickly. ' + - 'This endpoint is fully paginated — there is no truncation. Default pageSize=200; raise it (up to 1000) for ' + - 'full-table dumps. Pass the whole nextLink string as "continuation" so all backend-echoed query params round-trip ' + - 'correctly.', { - tableName: z.string().describe('Table name (e.g. "Sessions", "Events", "RuleResults", "TenantConfiguration")'), - partitionKey: z.string().optional().describe('Filter by exact partition key (usually TenantId)'), - rowKeyPrefix: z.string().optional().describe('Filter by row key prefix'), - filter: z.string().optional().describe('OData filter expression (e.g. "Status eq \'Failed\'")'), - fields: z.string().optional() - .describe('Comma-separated column names to keep (e.g. "PartitionKey,RowKey,Status"). Other columns are dropped ' + - 'client-side after fetch. Useful for aggregation/counting on wide tables. Always includes PartitionKey ' + - 'and RowKey for cursor stability.'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). Returns this many rows per call; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + title: 'Query Table', + description: + 'Query any Azure Table Storage table directly with OData filters. Global Admin only. ' + + 'Use list_tables to see available tables. Useful for inspecting TenantConfiguration, RuleResults, or any raw data ' + + 'where no specialized tool exists. ' + + 'For COUNTING / AGGREGATION queries pass `fields=PartitionKey,RowKey,Status,AgentVersion` (or similar lean subset) ' + + 'to drop unneeded columns client-side — full TableEntity rows can be 1KB+ each and trip the response cap quickly. ' + + 'This endpoint is fully paginated — there is no truncation. Default pageSize=200; raise it (up to 1000) for ' + + 'full-table dumps. Pass the whole nextLink string as "continuation" so all backend-echoed query params round-trip ' + + 'correctly.', + inputSchema: { + tableName: z.string().describe('Table name (e.g. "Sessions", "Events", "RuleResults", "TenantConfiguration")'), + partitionKey: z.string().optional().describe('Filter by exact partition key (usually TenantId)'), + rowKeyPrefix: z.string().optional().describe('Filter by row key prefix'), + filter: z.string().optional().describe('OData filter expression (e.g. "Status eq \'Failed\'")'), + fields: z.string().optional() + .describe('Comma-separated column names to keep (e.g. "PartitionKey,RowKey,Status"). Other columns are dropped ' + + 'client-side after fetch. Useful for aggregation/counting on wide tables. Always includes PartitionKey ' + + 'and RowKey for cursor stability.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many rows per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('query_table', async () => { try { const { tableName, partitionKey, rowKeyPrefix, filter, fields, pageSize, continuation } = args; @@ -611,15 +662,19 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool 22: query_backend_logs — Global Admin only; not registered for normal users. - if (ga) server.tool( + if (ga) server.registerTool( 'query_backend_logs', - 'Query backend Application Insights logs using KQL. Global Admin only. ' + - 'Use for debugging backend issues, tracing requests by correlation ID, and platform diagnostics.', { - query: z.string().describe('KQL query (e.g. "traces | where message contains \'error\' | take 50")'), - timespan: z.string().optional().default('PT1H').describe('ISO 8601 duration (default: PT1H = last 1 hour). Examples: PT30M, PT6H, P1D'), + title: 'Query Backend Logs', + description: + 'Query backend Application Insights logs using KQL. Global Admin only. ' + + 'Use for debugging backend issues, tracing requests by correlation ID, and platform diagnostics.', + inputSchema: { + query: z.string().describe('KQL query (e.g. "traces | where message contains \'error\' | take 50")'), + timespan: z.string().optional().default('PT1H').describe('ISO 8601 duration (default: PT1H = last 1 hour). Examples: PT30M, PT6H, P1D'), + }, + annotations: READ_ONLY_OPEN, }, - READ_ONLY_OPEN, async (args) => withToolTelemetry('query_backend_logs', async () => { try { const data = await apiFetch('/api/global/raw/logs', { @@ -634,22 +689,26 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool: get_rule_stats - server.tool( + server.registerTool( 'get_rule_stats', - 'Get rule firing statistics for analyze and gather rules. Shows which rules fire most often, ' + - 'their hit rates (fires/evaluations), and daily trends. Use to identify commonly triggered rules, ' + - 'optimize rule definitions, or understand tenant-specific failure patterns. ' + - (ga ? 'Without tenantId returns global stats (cross-tenant). With tenantId returns tenant-specific stats. ' : '') + - 'NOTE: default window is 30 days × every rule × per-day trend rows — for 20+ rules the response ' + - 'easily exceeds 70 KB and can trip the response cap. Pass a tighter `startDate`/`endDate` window ' + - '(7 days is usually plenty) and/or `ruleType` filter to keep responses lean.', { - tenantId: z.string().optional().describe(ga ? 'Filter by tenant ID. Omit for global (cross-tenant) stats.' : 'Optional tenant ID. Defaults to your tenant.'), - ruleType: z.enum(['analyze', 'gather']).optional().describe('Filter by rule type'), - startDate: z.string().optional().describe('Start date (YYYY-MM-DD). Defaults to 30 days ago.'), - endDate: z.string().optional().describe('End date (YYYY-MM-DD). Defaults to today.'), + title: 'Rule Statistics', + description: + 'Get rule firing statistics for analyze and gather rules. Shows which rules fire most often, ' + + 'their hit rates (fires/evaluations), and daily trends. Use to identify commonly triggered rules, ' + + 'optimize rule definitions, or understand tenant-specific failure patterns. ' + + (ga ? 'Without tenantId returns global stats (cross-tenant). With tenantId returns tenant-specific stats. ' : '') + + 'NOTE: default window is 30 days × every rule × per-day trend rows — for 20+ rules the response ' + + 'easily exceeds 70 KB and can trip the response cap. Pass a tighter `startDate`/`endDate` window ' + + '(7 days is usually plenty) and/or `ruleType` filter to keep responses lean.', + inputSchema: { + tenantId: z.string().optional().describe(ga ? 'Filter by tenant ID. Omit for global (cross-tenant) stats.' : 'Optional tenant ID. Defaults to your tenant.'), + ruleType: z.enum(['analyze', 'gather']).optional().describe('Filter by rule type'), + startDate: z.string().optional().describe('Start date (YYYY-MM-DD). Defaults to 30 days ago.'), + endDate: z.string().optional().describe('End date (YYYY-MM-DD). Defaults to today.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_rule_stats', async () => { try { const params: Record = { @@ -668,24 +727,28 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { ); // Tool: get_vulnerability_summary - server.tool( + server.registerTool( 'get_vulnerability_summary', - 'Get a fleet-wide vulnerability exposure summary aggregated from detected CVEs: total affected devices, ' + - 'distinct CVE count, KEV (CISA Known-Exploited) count, a severity breakdown, and the top CVEs ranked by how ' + - 'many devices they affect. ' + - (ga ? 'Omit tenantId for a cross-tenant overview (Global Admin; also returns affected tenant count); pass tenantId to scope to one tenant. ' : '') + - 'Use this to answer "how exposed is the fleet / this ' + - 'tenant?" and "which CVEs affect the most devices?" — for the device list of a single CVE use search_sessions_by_cve. ' + - 'If "truncated" is true, the underlying index scan hit its cap and counts are a lower bound (narrow with tenantId). ' + - 'Requires vulnerability scanning to be enabled (an empty summary means no findings, not necessarily "not affected").', { - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant overview (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - days: z.coerce.number().int().min(1).max(365).optional().default(30) - .describe('Time window in days (1-365, default 30). Filters CVEs by when they were detected.'), - topN: z.coerce.number().int().min(1).max(100).optional().default(20) - .describe('How many top CVEs to return, ranked by affected device count (1-100, default 20).'), + title: 'Vulnerability Summary', + description: + 'Get a fleet-wide vulnerability exposure summary aggregated from detected CVEs: total affected devices, ' + + 'distinct CVE count, KEV (CISA Known-Exploited) count, a severity breakdown, and the top CVEs ranked by how ' + + 'many devices they affect. ' + + (ga ? 'Omit tenantId for a cross-tenant overview (Global Admin; also returns affected tenant count); pass tenantId to scope to one tenant. ' : '') + + 'Use this to answer "how exposed is the fleet / this ' + + 'tenant?" and "which CVEs affect the most devices?" — for the device list of a single CVE use search_sessions_by_cve. ' + + 'If "truncated" is true, the underlying index scan hit its cap and counts are a lower bound (narrow with tenantId). ' + + 'Requires vulnerability scanning to be enabled (an empty summary means no findings, not necessarily "not affected").', + inputSchema: { + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant overview (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + days: z.coerce.number().int().min(1).max(365).optional().default(30) + .describe('Time window in days (1-365, default 30). Filters CVEs by when they were detected.'), + topN: z.coerce.number().int().min(1).max(100).optional().default(20) + .describe('How many top CVEs to return, ranked by affected device count (1-100, default 20).'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_vulnerability_summary', async () => { try { const { tenantId, days, topN } = args; @@ -701,17 +764,21 @@ export function registerAdminTools(server: McpServer, ga: boolean): void { // Tool: get_resource — returns the same content as the MCP `resources` would, // but via a regular tool call. Workaround for clients (e.g. Claude Code's HTTP-MCP // bridge in stateless mode) that don't expose `resources/list` reliably. - server.tool( + server.registerTool( 'get_resource', - 'Returns the contents of a named static catalog resource. Use this when ' + - 'the host MCP client cannot list/read MCP-protocol resources (common with ' + - 'stateless HTTP MCP servers). Available names:\n' + - ' - "event_types": catalog of valid eventType strings for search_sessions_by_event\n' + - ' - "device_properties": catalog of dot-notation keys for the deviceProperties filter on search_sessions', { - name: z.enum(['event_types', 'device_properties']).describe('Resource name'), + title: 'Get Catalog Resource', + description: + 'Returns the contents of a named static catalog resource. Use this when ' + + 'the host MCP client cannot list/read MCP-protocol resources (common with ' + + 'stateless HTTP MCP servers). Available names:\n' + + ' - "event_types": catalog of valid eventType strings for search_sessions_by_event\n' + + ' - "device_properties": catalog of dot-notation keys for the deviceProperties filter on search_sessions', + inputSchema: { + name: z.enum(['event_types', 'device_properties']).describe('Resource name'), + }, + annotations: READ_ONLY_OPEN, }, - READ_ONLY_OPEN, async (args) => withToolTelemetry('get_resource', async () => { try { const data = getResourceContent(args.name); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts index 056fe383..323612dc 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts @@ -346,24 +346,28 @@ function scoreEvent(e: EventEntry, queryKeywords: string[]): ScoredEvent | null export function registerSearchTools(server: McpServer, knowledgeBase: SearchProvider | undefined, ga: boolean): void { // Tool 9: search_events_semantic — weighted keyword search - server.tool( + server.registerTool( 'search_events_semantic', - 'TIER 1 — FAST EVENT SEARCH (try this first). ' + - 'Searches enrollment events by matching keywords against event type, message, source, severity, and data fields. ' + - 'Uses prefix-aware matching (e.g. "install" matches "installation", "installed", "app_install_failed") ' + - 'and weighted field scoring (matches in eventType rank higher than in data). ' + - 'Provide sessionId to search within one session, or omit for a fast (single-page-per-type) cross-session scan. ' + - 'This is a bounded scan: check the returned `truncated` flag — if true, recall is incomplete, so escalate to ' + - 'deep_search_events for a broader multi-page scan or narrow the query as `recallNote` suggests.', { - query: z.string().describe('Natural language description of what to find (e.g. "app download stuck", "certificate error", "disk space low")'), - sessionId: SessionIdSchema.optional().describe('Search within a specific session. If omitted, searches across recent failed sessions.'), - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Required for non-Global Admin users; Global Admin can omit to search across tenants.' : 'Optional tenant ID. Defaults to your tenant.'), - topK: z.coerce.number().min(1).max(30).optional().default(10).describe('Number of matching events to return (1-30, default 10)'), - minScore: z.coerce.number().min(0).max(1).optional().default(0.1) - .describe('Minimum relevance score (0-1, default 0.1). Events matching at least one keyword in any field pass this threshold.'), + title: 'Search Events (Semantic)', + description: + 'TIER 1 — FAST EVENT SEARCH (try this first). ' + + 'Searches enrollment events by matching keywords against event type, message, source, severity, and data fields. ' + + 'Uses prefix-aware matching (e.g. "install" matches "installation", "installed", "app_install_failed") ' + + 'and weighted field scoring (matches in eventType rank higher than in data). ' + + 'Provide sessionId to search within one session, or omit for a fast (single-page-per-type) cross-session scan. ' + + 'This is a bounded scan: check the returned `truncated` flag — if true, recall is incomplete, so escalate to ' + + 'deep_search_events for a broader multi-page scan or narrow the query as `recallNote` suggests.', + inputSchema: { + query: z.string().describe('Natural language description of what to find (e.g. "app download stuck", "certificate error", "disk space low")'), + sessionId: SessionIdSchema.optional().describe('Search within a specific session. If omitted, searches across recent failed sessions.'), + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Required for non-Global Admin users; Global Admin can omit to search across tenants.' : 'Optional tenant ID. Defaults to your tenant.'), + topK: z.coerce.number().min(1).max(30).optional().default(10).describe('Number of matching events to return (1-30, default 10)'), + minScore: z.coerce.number().min(0).max(1).optional().default(0.1) + .describe('Minimum relevance score (0-1, default 0.1). Events matching at least one keyword in any field pass this threshold.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('search_events_semantic', async () => { try { const { query, sessionId, tenantId, topK, minScore } = args; @@ -419,21 +423,25 @@ export function registerSearchTools(server: McpServer, knowledgeBase: SearchProv ); // Tool 10: search_knowledge (unchanged — vector, pre-indexed at startup) - server.tool( + server.registerTool( 'search_knowledge', - 'Semantic/fuzzy search over the Autopilot Monitor knowledge base: analysis rules, gather rules, and IME log patterns. ' + - 'Use natural language queries like "app install timeout", "BitLocker issues", "detection script failure". ' + - 'Returns the most relevant rules and patterns ranked by similarity. ' + - 'Great for finding remediation steps, understanding error patterns, or discovering relevant diagnostic rules.', { - query: z.string().describe('Natural language search query (e.g. "app download timeout", "TPM not ready", "ESP stuck")'), - topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'), - type: z.enum(['all', 'analyze-rule', 'gather-rule', 'ime-log-pattern']).optional().default('all') - .describe('Filter by document type. Default: search all types.'), - minScore: z.coerce.number().min(0).max(1).optional().default(0.3) - .describe('Minimum similarity score threshold (0-1, default 0.3). Lower = more results, higher = stricter matching.'), + title: 'Search Knowledge Base', + description: + 'Semantic/fuzzy search over the Autopilot Monitor knowledge base: analysis rules, gather rules, and IME log patterns. ' + + 'Use natural language queries like "app install timeout", "BitLocker issues", "detection script failure". ' + + 'Returns the most relevant rules and patterns ranked by similarity. ' + + 'Great for finding remediation steps, understanding error patterns, or discovering relevant diagnostic rules.', + inputSchema: { + query: z.string().describe('Natural language search query (e.g. "app download timeout", "TPM not ready", "ESP stuck")'), + topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'), + type: z.enum(['all', 'analyze-rule', 'gather-rule', 'ime-log-pattern']).optional().default('all') + .describe('Filter by document type. Default: search all types.'), + minScore: z.coerce.number().min(0).max(1).optional().default(0.3) + .describe('Minimum similarity score threshold (0-1, default 0.3). Lower = more results, higher = stricter matching.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('search_knowledge', async () => { try { const { query, topK, type, minScore } = args; @@ -479,30 +487,34 @@ export function registerSearchTools(server: McpServer, knowledgeBase: SearchProv ); // Tool 23: deep_search_events — same scoring, broader paginated scan + lower thresholds - server.tool( + server.registerTool( 'deep_search_events', - 'TIER 3 — DEEP SEARCH (thorough, use when accuracy is critical). ' + - 'Uses the same weighted keyword scoring as search_events_semantic but with lower thresholds and a broader, ' + - 'paginated scan: for each event type matched from the query it walks the cross-session index, following pages ' + - 'up to a generous budget, then ranks the matches. Scores across ALL event fields including full DataJson content. ' + - 'Returns the top `topK` ranked matches (NOT every matching event) — compare `resultCount` vs `eventsMatched`, ' + - 'and use get_session_events / search_sessions_by_event for full per-event recall. ' + - 'Check the `truncated` flag: if true, the scan hit its page/time budget (or fell back to recent failed ' + - 'sessions) and recall is incomplete — narrow the query as `recallNote` suggests. ' + - 'Provide sessionId to search within one session (complete, no truncation), or omit to search across sessions.' + - (ga ? ' Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant.' : ''), { - query: z.string().describe('Natural language description of what to find (e.g. "app download stuck", "certificate error", "disk space low")'), - sessionId: SessionIdSchema.optional().describe('Search within a specific session. If omitted, searches across recent failed sessions.'), - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Required for non-Global Admin users; Global Admin can omit to search across tenants.' : 'Optional tenant ID. Defaults to your tenant.'), - topK: z.coerce.number().min(1).max(50).optional().default(20) - .describe('Max results to return (1-50, default 20). Higher default for thoroughness.'), - minScore: z.coerce.number().min(0).max(1).optional().default(0.05) - .describe('Min relevance score (0-1, default 0.05). Very low so weakly-matching events still surface.'), - keywords: z.array(z.string()).optional() - .describe('Additional exact keywords for matching. Auto-extracted from query if omitted.'), + title: 'Deep Search Events', + description: + 'TIER 3 — DEEP SEARCH (thorough, use when accuracy is critical). ' + + 'Uses the same weighted keyword scoring as search_events_semantic but with lower thresholds and a broader, ' + + 'paginated scan: for each event type matched from the query it walks the cross-session index, following pages ' + + 'up to a generous budget, then ranks the matches. Scores across ALL event fields including full DataJson content. ' + + 'Returns the top `topK` ranked matches (NOT every matching event) — compare `resultCount` vs `eventsMatched`, ' + + 'and use get_session_events / search_sessions_by_event for full per-event recall. ' + + 'Check the `truncated` flag: if true, the scan hit its page/time budget (or fell back to recent failed ' + + 'sessions) and recall is incomplete — narrow the query as `recallNote` suggests. ' + + 'Provide sessionId to search within one session (complete, no truncation), or omit to search across sessions.' + + (ga ? ' Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant.' : ''), + inputSchema: { + query: z.string().describe('Natural language description of what to find (e.g. "app download stuck", "certificate error", "disk space low")'), + sessionId: SessionIdSchema.optional().describe('Search within a specific session. If omitted, searches across recent failed sessions.'), + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Required for non-Global Admin users; Global Admin can omit to search across tenants.' : 'Optional tenant ID. Defaults to your tenant.'), + topK: z.coerce.number().min(1).max(50).optional().default(20) + .describe('Max results to return (1-50, default 20). Higher default for thoroughness.'), + minScore: z.coerce.number().min(0).max(1).optional().default(0.05) + .describe('Min relevance score (0-1, default 0.05). Very low so weakly-matching events still surface.'), + keywords: z.array(z.string()).optional() + .describe('Additional exact keywords for matching. Auto-extracted from query if omitted.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('deep_search_events', async () => { try { const { query, sessionId, tenantId, topK, minScore, keywords } = args; diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 38f27b07..c8f09e27 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -43,64 +43,68 @@ const phaseName = (phase: unknown, enrollmentType: unknown): string => { export function registerSessionTools(server: McpServer, ga: boolean): void { // Tool 1: search_sessions - server.tool( + server.registerTool( 'search_sessions', - 'Search enrollment sessions' + - (ga ? '. Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant' : ' in your tenant') + '. ' + - 'Basic properties (status, serial number, manufacturer, model, etc.) filter on the session index. ' + - 'Use deviceProperties for ANY device hardware/config filter — keys use "eventType.propertyName" notation. ' + - 'Consult the device_properties catalog (call get_resource(name="device_properties")) for available keys. ' + - 'Examples: {"tpm_status.specVersion": "2.0"}, {"hardware_spec.ramTotalGB": ">=8"}, {"secureboot_status.uefiSecureBootEnabled": "True"}. ' + - 'Array values are searched as substring match (e.g. disks containing "NVMe"). ' + - 'For COUNTING / AGGREGATION queries (e.g. "how many V2 enrollments?", "how many failed in last 7 days?") ALWAYS pass ' + - '`fields=sessionId,status,agentVersion,startedAt` (or similar lean subset) — full SessionSummary objects are ~1.5KB ' + - 'each and trip the response cap before pagination would normally deliver the answer. With projection a 100-session ' + - 'aggregate fits in <10KB. ' + - 'For VERSION sweeps use `agentVersionPrefix=2.0.` or `imeAgentVersionPrefix=1.23.` instead of one call per build — ' + - 'matches every patch in the line in a single response. ' + - 'This endpoint is fully paginated — there is no truncation. Default pageSize=200 is tuned for interactive queries; ' + - 'raise it (up to 1000) for full sweeps. Pass the whole nextLink string as "continuation" so all backend-echoed ' + - 'query params round-trip correctly.', { - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant search (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - status: z.enum(['InProgress', 'Pending', 'Stalled', 'Succeeded', 'Failed']).optional() - .describe('Enrollment status filter. Pending = White Glove pre-provisioning done, awaiting user enrollment; ' + - 'Stalled = no progress for a while (non-terminal, can heal back to InProgress).'), - serialNumber: z.string().optional().describe('Device serial number (exact match)'), - deviceName: z.string().optional().describe('Device name (prefix match, e.g. "DESKTOP-")'), - manufacturer: z.string().optional().describe('Hardware manufacturer (e.g. "Microsoft", "Dell", "HP")'), - model: z.string().optional().describe('Hardware model (e.g. "Surface Pro 9")'), - osBuild: z.string().optional().describe('OS build number prefix (e.g. "26100")'), - enrollmentType: z.enum(['v1', 'v2']).optional().describe('Autopilot enrollment type'), - isPreProvisioned: z.boolean().optional().describe('Filter by White Glove / pre-provisioned enrollment'), - isHybridJoin: z.boolean().optional().describe('Filter by Hybrid Azure AD Join'), - geoCountry: z.string().optional().describe('Country of enrollment (2-letter ISO code, e.g. "DE", "US")'), - startedAfter: z.string().optional().describe('ISO 8601 datetime — only sessions started after this'), - startedBefore: z.string().optional().describe('ISO 8601 datetime — only sessions started before this'), - agentVersion: z.string().optional().describe('Monitor Agent version (exact match, e.g. "2.0.626")'), - agentVersionPrefix: z.string().optional() - .describe('Monitor Agent version prefix (e.g. "2.0." matches every 2.0.x build). Mutually exclusive with agentVersion (exact wins).'), - imeAgentVersion: z.string().optional().describe('IME Agent version (exact match, e.g. "1.23.456.789")'), - imeAgentVersionPrefix: z.string().optional() - .describe('IME Agent version prefix (e.g. "1.23." matches every 1.23.x build). Mutually exclusive with imeAgentVersion.'), - fields: z.string().optional() - .describe('Comma-separated lean projection (e.g. "sessionId,status,agentVersion,startedAt"). ' + - 'Use for counting / aggregation to avoid the response cap. Available: sessionId, tenantId, status, ' + - 'serialNumber, manufacturer, model, deviceName, osBuild, osName, startedAt, completedAt, ' + - 'durationSeconds, currentPhase, failureReason, eventCount, enrollmentType, isPreProvisioned, ' + - 'isUserDriven, isHybridJoin, agentVersion, imeAgentVersion, geoCountry.'), - deviceProperties: z.record(z.string(), z.string()).optional().describe( - 'Dynamic device property filters. Keys use "eventType.propertyName" dot notation. ' + - 'See the device_properties catalog (call get_resource(name="device_properties")) for all available keys and types. ' + - 'Values: exact match by default. Prefix with >=, <=, >, < for numeric ranges (e.g. ">=8"). ' + - 'Booleans: use "True" or "False". Arrays: substring match in any element.' - ), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). Returns this many sessions per call; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + title: 'Search Sessions', + description: + 'Search enrollment sessions' + + (ga ? '. Omit tenantId for cross-tenant search (Global Admin), or specify tenantId for single-tenant' : ' in your tenant') + '. ' + + 'Basic properties (status, serial number, manufacturer, model, etc.) filter on the session index. ' + + 'Use deviceProperties for any device hardware/config filter — keys use "eventType.propertyName" notation. ' + + 'Consult the device_properties catalog (call get_resource(name="device_properties")) for available keys. ' + + 'Examples: {"tpm_status.specVersion": "2.0"}, {"hardware_spec.ramTotalGB": ">=8"}, {"secureboot_status.uefiSecureBootEnabled": "True"}. ' + + 'Array values are searched as substring match (e.g. disks containing "NVMe"). ' + + 'For COUNTING / AGGREGATION queries (e.g. "how many V2 enrollments?", "how many failed in last 7 days?") pass ' + + '`fields=sessionId,status,agentVersion,startedAt` (or a similar lean subset): full SessionSummary objects are ~1.5KB ' + + 'each and can trip the response cap before pagination would normally deliver the answer. With projection a 100-session ' + + 'aggregate fits in <10KB. ' + + 'For VERSION sweeps use `agentVersionPrefix=2.0.` or `imeAgentVersionPrefix=1.23.` instead of one call per build — ' + + 'matches every patch in the line in a single response. ' + + 'This endpoint is fully paginated — there is no truncation. Default pageSize=200 is tuned for interactive queries; ' + + 'raise it (up to 1000) for full sweeps. Pass the whole nextLink string as "continuation" so all backend-echoed ' + + 'query params round-trip correctly.', + inputSchema: { + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant search (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + status: z.enum(['InProgress', 'Pending', 'Stalled', 'Succeeded', 'Failed']).optional() + .describe('Enrollment status filter. Pending = White Glove pre-provisioning done, awaiting user enrollment; ' + + 'Stalled = no progress for a while (non-terminal, can heal back to InProgress).'), + serialNumber: z.string().optional().describe('Device serial number (exact match)'), + deviceName: z.string().optional().describe('Device name (prefix match, e.g. "DESKTOP-")'), + manufacturer: z.string().optional().describe('Hardware manufacturer (e.g. "Microsoft", "Dell", "HP")'), + model: z.string().optional().describe('Hardware model (e.g. "Surface Pro 9")'), + osBuild: z.string().optional().describe('OS build number prefix (e.g. "26100")'), + enrollmentType: z.enum(['v1', 'v2']).optional().describe('Autopilot enrollment type'), + isPreProvisioned: z.boolean().optional().describe('Filter by White Glove / pre-provisioned enrollment'), + isHybridJoin: z.boolean().optional().describe('Filter by Hybrid Azure AD Join'), + geoCountry: z.string().optional().describe('Country of enrollment (2-letter ISO code, e.g. "DE", "US")'), + startedAfter: z.string().optional().describe('ISO 8601 datetime — only sessions started after this'), + startedBefore: z.string().optional().describe('ISO 8601 datetime — only sessions started before this'), + agentVersion: z.string().optional().describe('Monitor Agent version (exact match, e.g. "2.0.626")'), + agentVersionPrefix: z.string().optional() + .describe('Monitor Agent version prefix (e.g. "2.0." matches every 2.0.x build). Mutually exclusive with agentVersion (exact wins).'), + imeAgentVersion: z.string().optional().describe('IME Agent version (exact match, e.g. "1.23.456.789")'), + imeAgentVersionPrefix: z.string().optional() + .describe('IME Agent version prefix (e.g. "1.23." matches every 1.23.x build). Mutually exclusive with imeAgentVersion.'), + fields: z.string().optional() + .describe('Comma-separated lean projection (e.g. "sessionId,status,agentVersion,startedAt"). ' + + 'Use for counting / aggregation to avoid the response cap. Available: sessionId, tenantId, status, ' + + 'serialNumber, manufacturer, model, deviceName, osBuild, osName, startedAt, completedAt, ' + + 'durationSeconds, currentPhase, failureReason, eventCount, enrollmentType, isPreProvisioned, ' + + 'isUserDriven, isHybridJoin, agentVersion, imeAgentVersion, geoCountry.'), + deviceProperties: z.record(z.string(), z.string()).optional().describe( + 'Dynamic device property filters. Keys use "eventType.propertyName" dot notation. ' + + 'See the device_properties catalog (call get_resource(name="device_properties")) for all available keys and types. ' + + 'Values: exact match by default. Prefix with >=, <=, >, < for numeric ranges (e.g. ">=8"). ' + + 'Booleans: use "True" or "False". Arrays: substring match in any element.' + ), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many sessions per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('search_sessions', async () => { try { const { deviceProperties, tenantId, pageSize, continuation, ...rest } = args; @@ -126,25 +130,29 @@ export function registerSessionTools(server: McpServer, ga: boolean): void { ); // Tool 2: search_sessions_by_event - server.tool( + server.registerTool( 'search_sessions_by_event', - 'Find sessions that contain a specific event type (e.g. app install failure, phase transitions, errors). ' + - (ga ? 'Omit tenantId for cross-tenant search (Global Admin). ' : '') + - 'Check the event_types catalog (call get_resource(name="event_types")) for valid eventType values. ' + - 'Use this to answer: which devices had a failed Teams install, which sessions had an error in DeviceSetup phase. ' + - 'This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical ' + - 'interactive queries; raise it (up to 1000) for full sweeps. For broad analysis, use pageSize=1000 and follow ' + - 'nextLink repeatedly until absent. Pass the whole nextLink string as "continuation" so all backend-echoed query ' + - 'params round-trip correctly.', { - eventType: z.string().describe('Event type string — see event_types catalog (call get_resource(name="event_types")) for valid values (e.g. "app_install_failed", "enrollment_failed")'), - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant search (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). Returns this many sessions per call; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + title: 'Search Sessions by Event', + description: + 'Find sessions that contain a specific event type (e.g. app install failure, phase transitions, errors). ' + + (ga ? 'Omit tenantId for cross-tenant search (Global Admin). ' : '') + + 'Check the event_types catalog (call get_resource(name="event_types")) for valid eventType values. ' + + 'Use this to answer: which devices had a failed Teams install, which sessions had an error in DeviceSetup phase. ' + + 'This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical ' + + 'interactive queries; raise it (up to 1000) for full sweeps. For broad analysis, use pageSize=1000 and follow ' + + 'nextLink repeatedly until absent. Pass the whole nextLink string as "continuation" so all backend-echoed query ' + + 'params round-trip correctly.', + inputSchema: { + eventType: z.string().describe('Event type string — see event_types catalog (call get_resource(name="event_types")) for valid values (e.g. "app_install_failed", "enrollment_failed")'), + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant search (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many sessions per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('search_sessions_by_event', async () => { try { const { eventType, tenantId, pageSize, continuation } = args; @@ -163,15 +171,18 @@ export function registerSessionTools(server: McpServer, ga: boolean): void { ); // Tool 3: get_session - server.tool( + server.registerTool( 'get_session', - 'Get full details of a single enrollment session including all device metadata. Set includeAnalysis=true to also get AI rule analysis results explaining why the session failed and remediation suggestions.', { - sessionId: SessionIdSchema.describe('Session UUID'), - tenantId: z.string().optional().describe(ga ? 'Tenant ID. If omitted, auto-resolved from the session (Global Admin can access any tenant).' : 'Tenant ID. If omitted, auto-resolved from the session.'), - includeAnalysis: z.boolean().optional().default(false).describe('Include rule analysis results (failure explanations and remediation steps)'), + title: 'Get Session', + description: 'Get full details of a single enrollment session including all device metadata. Set includeAnalysis=true to also get AI rule analysis results explaining why the session failed and remediation suggestions.', + inputSchema: { + sessionId: SessionIdSchema.describe('Session UUID'), + tenantId: z.string().optional().describe(ga ? 'Tenant ID. If omitted, auto-resolved from the session (Global Admin can access any tenant).' : 'Tenant ID. If omitted, auto-resolved from the session.'), + includeAnalysis: z.boolean().optional().default(false).describe('Include rule analysis results (failure explanations and remediation steps)'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_session', async () => { try { const { sessionId, tenantId, includeAnalysis } = args; @@ -193,30 +204,34 @@ export function registerSessionTools(server: McpServer, ga: boolean): void { ); // Tool 4: get_session_events - server.tool( + server.registerTool( 'get_session_events', - 'TIER 2 — RAW EVENT RETRIEVAL (fallback when semantic search misses). ' + - 'Returns up to pageSize events from a single session. Filter by eventType, severity, or source (app name). ' + - 'Use this when search_events_semantic returns incomplete results and you need the full unfiltered event stream, ' + - 'or for root cause analysis when you need every event in chronological sequence. ' + - 'If you omit tenantId, the backend auto-resolves it from the session' + (ga ? ' (Global Admin can access any tenant)' : '') + '. ' + - 'Pagination: if the response includes "nextLink", more events are available — call this tool again and pass the ' + - 'whole nextLink string (e.g. "/api/sessions/{id}/events?pageSize=...&continuation=...&tenantId=...") as ' + - '"continuation". The tool follows it verbatim so query params the backend echoes (tenantId, ' + - 'filters, etc.) round-trip correctly. Stop when the response no longer contains a nextLink. Sessions with ' + - 'thousands of events are fully reachable across multiple calls.', { - sessionId: SessionIdSchema.describe('Session UUID'), - tenantId: z.string().optional().describe('Tenant ID. If omitted, auto-resolved from the session.'), - eventType: z.string().optional().describe('Filter to only events of this type'), - severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(), - source: z.string().optional().describe('Filter by event source/app name (e.g. "MicrosoftTeams")'), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). The endpoint returns this many events per call; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so query params the backend echoes round-trip correctly.'), + title: 'Get Session Events', + description: + 'TIER 2 — RAW EVENT RETRIEVAL (fallback when semantic search misses). ' + + 'Returns up to pageSize events from a single session. Filter by eventType, severity, or source (app name). ' + + 'Use this when search_events_semantic returns incomplete results and you need the full unfiltered event stream, ' + + 'or for root cause analysis when you need every event in chronological sequence. ' + + 'If you omit tenantId, the backend auto-resolves it from the session' + (ga ? ' (Global Admin can access any tenant)' : '') + '. ' + + 'Pagination: if the response includes "nextLink", more events are available — call this tool again and pass the ' + + 'whole nextLink string (e.g. "/api/sessions/{id}/events?pageSize=...&continuation=...&tenantId=...") as ' + + '"continuation". The tool follows it verbatim so query params the backend echoes (tenantId, ' + + 'filters, etc.) round-trip correctly. Stop when the response no longer contains a nextLink. Sessions with ' + + 'thousands of events are fully reachable across multiple calls.', + inputSchema: { + sessionId: SessionIdSchema.describe('Session UUID'), + tenantId: z.string().optional().describe('Tenant ID. If omitted, auto-resolved from the session.'), + eventType: z.string().optional().describe('Filter to only events of this type'), + severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(), + source: z.string().optional().describe('Filter by event source/app name (e.g. "MicrosoftTeams")'), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). The endpoint returns this many events per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so query params the backend echoes round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_session_events', async () => { try { const { sessionId, tenantId, pageSize, continuation, eventType, severity, source } = args; @@ -237,21 +252,25 @@ export function registerSessionTools(server: McpServer, ga: boolean): void { ); // Tool 5: get_session_summary - server.tool( + server.registerTool( 'get_session_summary', - 'Get a concise, structured summary of an enrollment session optimized for analysis. ' + - 'Returns: session overview (status, duration, device, enrollment config), ' + - 'key events timeline (errors, warnings, phase transitions, app installs — noise filtered out, ' + - 'capped at 50 most-relevant entries; stats.keyEventsTruncated indicates if more were dropped), ' + - 'rule analysis results (probable cause, remediation), and aggregate stats. Heavy event payloads ' + - '(data JSON) are NOT included — pull them via get_session_events for the same sessionId when needed. ' + - 'Use this as the FIRST tool when investigating a session. ' + - 'For raw unfiltered events use get_session_events. For full metadata use get_session.', { - sessionId: SessionIdSchema.describe('Session UUID'), - tenantId: z.string().optional().describe(ga ? 'Tenant ID. If omitted, auto-resolved from the session (Global Admin can access any tenant).' : 'Tenant ID. If omitted, auto-resolved from the session.'), + title: 'Get Session Summary', + description: + 'Get a concise, structured summary of an enrollment session optimized for analysis. ' + + 'Returns: session overview (status, duration, device, enrollment config), ' + + 'key events timeline (errors, warnings, phase transitions, app installs — noise filtered out, ' + + 'capped at 50 most-relevant entries; stats.keyEventsTruncated indicates if more were dropped), ' + + 'rule analysis results (probable cause, remediation), and aggregate stats. Heavy event payloads ' + + '(data JSON) are NOT included — pull them via get_session_events for the same sessionId when needed. ' + + 'Use this as the first tool when investigating a session. ' + + 'For raw unfiltered events use get_session_events. For full metadata use get_session.', + inputSchema: { + sessionId: SessionIdSchema.describe('Session UUID'), + tenantId: z.string().optional().describe(ga ? 'Tenant ID. If omitted, auto-resolved from the session (Global Admin can access any tenant).' : 'Tenant ID. If omitted, auto-resolved from the session.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_session_summary', async () => { try { const { sessionId, tenantId } = args; @@ -413,17 +432,21 @@ export function registerSessionTools(server: McpServer, ga: boolean): void { ); // Tool 6: get_metrics - server.tool( + server.registerTool( 'get_metrics', - 'Get aggregated enrollment metrics: failure rates, slowest/most-failing apps, session counts. ' + - (ga ? 'Omit tenantId for cross-tenant platform overview (Global Admin). Specify tenantId for single-tenant metrics. ' : '') + - 'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).', { - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant overview (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - days: z.coerce.number().int().min(1).max(365).optional().default(30) - .describe('Time window in days (1-365). Defaults to 30. Applied to both summary and app metrics.'), + title: 'Enrollment Metrics', + description: + 'Get aggregated enrollment metrics: failure rates, slowest/most-failing apps, session counts. ' + + (ga ? 'Omit tenantId for cross-tenant platform overview (Global Admin). Specify tenantId for single-tenant metrics. ' : '') + + 'days accepts any value 1-365 (e.g. 5, 7, 12, 30, 90).', + inputSchema: { + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant overview (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + days: z.coerce.number().int().min(1).max(365).optional().default(30) + .describe('Time window in days (1-365). Defaults to 30. Applied to both summary and app metrics.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('get_metrics', async () => { try { const { tenantId, ...rest } = args; @@ -443,27 +466,31 @@ export function registerSessionTools(server: McpServer, ga: boolean): void { ); // Tool 7: search_sessions_by_cve - server.tool( + server.registerTool( 'search_sessions_by_cve', - "Find enrollment sessions where a specific CVE was detected in the device's software inventory. " + - (ga ? "Omit tenantId for cross-tenant search (Global Admin). " : "") + - "Requires vulnerability scanning to be enabled. " + - "Use this to answer: which devices are affected by CVE-2024-XXXX, show all critical vulnerability sessions. " + - "This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical " + - "interactive queries; raise it (up to 1000) for full exposure audits. For \"how many of my devices have CVE-X\" " + - "use pageSize=1000 and follow nextLink repeatedly until absent. Pass the whole nextLink string as " + - "\"continuation\" so all backend-echoed query params (cveId, minCvssScore, overallRisk) round-trip correctly.", { - cveId: z.string().describe('CVE identifier (e.g. "CVE-2024-21447")'), - tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant search (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), - minCvssScore: z.coerce.number().min(0).max(10).optional().describe('Minimum CVSS score filter (e.g. 7.0 for high+critical)'), - overallRisk: z.enum(['low', 'medium', 'high', 'critical']).optional(), - pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) - .describe('Page size (1-1000, default 200). Returns this many affected sessions per call; follow nextLink to fetch more.'), - continuation: z.string().optional() - .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + title: 'Search Sessions by CVE', + description: + "Find enrollment sessions where a specific CVE was detected in the device's software inventory. " + + (ga ? "Omit tenantId for cross-tenant search (Global Admin). " : "") + + "Requires vulnerability scanning to be enabled. " + + "Use this to answer: which devices are affected by CVE-2024-XXXX, show all critical vulnerability sessions. " + + "This endpoint is fully paginated — there is no truncation. The default pageSize=200 is tuned for typical " + + "interactive queries; raise it (up to 1000) for full exposure audits. For \"how many of my devices have CVE-X\" " + + "use pageSize=1000 and follow nextLink repeatedly until absent. Pass the whole nextLink string as " + + "\"continuation\" so all backend-echoed query params (cveId, minCvssScore, overallRisk) round-trip correctly.", + inputSchema: { + cveId: z.string().describe('CVE identifier (e.g. "CVE-2024-21447")'), + tenantId: z.string().optional().describe(ga ? 'Tenant ID. Omit for cross-tenant search (Global Admin only).' : 'Optional tenant ID. Defaults to your tenant.'), + minCvssScore: z.coerce.number().min(0).max(10).optional().describe('Minimum CVSS score filter (e.g. 7.0 for high+critical)'), + overallRisk: z.enum(['low', 'medium', 'high', 'critical']).optional(), + pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200) + .describe('Page size (1-1000, default 200). Returns this many affected sessions per call; follow nextLink to fetch more.'), + continuation: z.string().optional() + .describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so backend-echoed query params round-trip correctly.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('search_sessions_by_cve', async () => { try { const { cveId, tenantId, minCvssScore, overallRisk, pageSize, continuation } = args; @@ -484,15 +511,19 @@ export function registerSessionTools(server: McpServer, ga: boolean): void { // Tool 8: list_blocked_devices — Global Admin only. Registered only for a GA, // so a normal tenant user never sees it in tools/list (no name, no hint). if (ga) { - server.tool( + server.registerTool( 'list_blocked_devices', - 'List devices currently blocked from enrolling. Blocked devices have their enrollment sessions rejected by the backend. ' + - 'Global Admin only — both the tenant-scoped (?tenantId=) and cross-tenant variants of this endpoint require Global Admin. ' + - 'Tenant Admins and Operators receive 403 (the backend manages the device block list as a platform-wide concern).', { - tenantId: z.string().optional().describe('Tenant ID to scope results. Optional — both forms require Global Admin.'), + title: 'List Blocked Devices', + description: + 'List devices currently blocked from enrolling. Blocked devices have their enrollment sessions rejected by the backend. ' + + 'Global Admin only — both the tenant-scoped (?tenantId=) and cross-tenant variants of this endpoint require Global Admin. ' + + 'Tenant Admins and Operators receive 403 (the backend manages the device block list as a platform-wide concern).', + inputSchema: { + tenantId: z.string().optional().describe('Tenant ID to scope results. Optional — both forms require Global Admin.'), + }, + annotations: READ_ONLY, }, - READ_ONLY, async (args) => withToolTelemetry('list_blocked_devices', async () => { try { const { tenantId } = args; @@ -510,14 +541,18 @@ export function registerSessionTools(server: McpServer, ga: boolean): void { } // end if (ga) — list_blocked_devices // Tool: get_ime_version_history - server.tool( + server.registerTool( 'get_ime_version_history', - 'Get the history of all IME (Intune Management Extension) agent versions seen across enrollments. ' + - 'Shows when each version was first and last seen, and how many sessions reported it. ' + - 'This is a permanent archive that survives data retention — useful for tracking Microsoft IME release rollouts over time. ' + - 'Available to all tenant members (no tenantId needed, data is global).', - {}, - READ_ONLY, + { + title: 'IME Version History', + description: + 'Get the history of all IME (Intune Management Extension) agent versions seen across enrollments. ' + + 'Shows when each version was first and last seen, and how many sessions reported it. ' + + 'This is a permanent archive that survives data retention — useful for tracking Microsoft IME release rollouts over time. ' + + 'Available to all tenant members (no tenantId needed, data is global).', + inputSchema: {}, + annotations: READ_ONLY, + }, async (args) => withToolTelemetry('get_ime_version_history', async () => { try { const data = await apiFetch('/api/metrics/ime-versions');