feat(mcp): tool/prompt/resource titles + registerTool migration, JSON-RPC body errors

Migrate all 29 tools, 3 prompts and 2 resources from the deprecated
.tool()/.prompt()/.resource() overloads to registerTool/registerPrompt/
registerResource, each with a human-readable title (Anthropic Directory
pass/fail criterion; drives host permission prompts and the tool picker).
title lives at the config-object top level; READ_ONLY/READ_ONLY_OPEN stay
pure ToolAnnotations, so shared.ts is unchanged.

Also:
- tool-design-2: soften the coercive "ALWAYS pass X" wording in
  search_sessions to advisory tone (Directory prompt-injection flag).
- protocol-transport-2: add a body-parse error handler so malformed JSON
  to /mcp returns a JSON-RPC -32700 envelope (-32600 for oversized) instead
  of Express's default HTML error page; other routes get JSON 400/413.

usability-efficiency-2 was already addressed (search handlers surface
truncated + recallSummary); only the registration wrapper changed there.

Bump 1.3.0 -> 1.4.0. tsc strict green; 52/52 security-guards tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-29 15:52:47 +02:00
parent d72a79b156
commit f562469229
7 changed files with 624 additions and 458 deletions
@@ -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": {
@@ -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}`);
});
@@ -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 {
@@ -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. ' +
@@ -42,22 +42,26 @@ export function extractTenantList(data: unknown): Record<string, unknown>[] {
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<string, string | undefined> = {
@@ -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);
@@ -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;
@@ -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');