@pmoses-s1/sentinelone-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Hyperautomation tools — sentinelone-hyperautomation skill
3
+ *
4
+ * Tools:
5
+ * ha_list_workflows List Hyperautomation workflows (with scope/state/sort filters)
6
+ * ha_get_workflow Get a single workflow by ID (+ optional revisionId)
7
+ * ha_archive_workflow Archive (soft-delete) a workflow
8
+ * ha_import_workflow Import (create) a workflow from JSON
9
+ * ha_export_workflow Export all workflows as a ZIP archive
10
+ *
11
+ * API root (confirmed via live network capture 2026-05-03):
12
+ * /web/api/v2.1/hyper-automate/api/v1
13
+ *
14
+ * Single-workflow fetch requires BOTH workflowId AND revisionId:
15
+ * GET /workflows/single/{workflowId}/{revisionId}
16
+ * The revisionId is workflow.version_id in the list response.
17
+ *
18
+ * Deletion is a soft-archive, not HTTP DELETE:
19
+ * POST /workflows/archive { workflowIds: [<uuid>, ...] }
20
+ *
21
+ * Export/import paths were NOT captured in the network trace — kept at their
22
+ * previously confirmed /public paths until the /v1 equivalents are verified.
23
+ */
24
+
25
+ import { apiGet, apiPost } from '../lib/s1.js';
26
+
27
+ // Confirmed base path from live network monitor (2026-05-03).
28
+ const HA_BASE = '/web/api/v2.1/hyper-automate/api/v1';
29
+
30
+ // Export/import confirmed on /public path during backtest; not re-captured under /v1.
31
+ const HA_PUBLIC = '/web/api/v2.1/hyper-automate/api/public';
32
+
33
+ export const tools = [
34
+ // ─── ha_list_workflows ────────────────────────────────────────────────────
35
+ {
36
+ name: 'ha_list_workflows',
37
+ description: `List SentinelOne Hyperautomation workflows. Returns workflow ID, version_id (revisionId for ha_get_workflow), name, state, status, trigger types, action types, scope, and timestamps. Supports filtering by siteId, state, and sorting. Use siteIds to scope to a specific site. State values: active, inactive, deactivated, draft. Requires Hyper Automate.view permission.`,
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ limit: {
42
+ type: 'number',
43
+ description: 'Max workflows per page (default 50, max 200).',
44
+ default: 50,
45
+ },
46
+ skip: {
47
+ type: 'number',
48
+ description: 'Offset for pagination (default 0).',
49
+ default: 0,
50
+ },
51
+ siteIds: {
52
+ type: 'string',
53
+ description: 'Comma-separated site IDs to scope results to (e.g. "2056852093198736293"). Omit for all accessible scopes.',
54
+ },
55
+ sortBy: {
56
+ type: 'string',
57
+ description: 'Sort field. Default: updated_at.',
58
+ enum: ['updated_at', 'created_at', 'name'],
59
+ default: 'updated_at',
60
+ },
61
+ sortOrder: {
62
+ type: 'string',
63
+ description: 'Sort direction.',
64
+ enum: ['asc', 'desc'],
65
+ default: 'desc',
66
+ },
67
+ },
68
+ required: [],
69
+ },
70
+ async handler({ limit = 50, skip = 0, siteIds, sortBy = 'updated_at', sortOrder = 'desc' } = {}) {
71
+ const params = {
72
+ limit: Math.min(limit, 200),
73
+ skip,
74
+ sortBy,
75
+ sortOrder,
76
+ };
77
+ if (siteIds) params.siteIds = siteIds;
78
+ const result = await apiGet(`${HA_BASE}/workflows`, params);
79
+ // Summarise for readability: include key fields the LLM needs for follow-up calls.
80
+ const items = (result?.data || []).map(item => ({
81
+ id: item.id,
82
+ revisionId: item.workflow?.version_id, // needed for ha_get_workflow
83
+ name: item.workflow?.name,
84
+ state: item.workflow?.state, // active | inactive | deactivated | draft
85
+ status: item.workflow?.status, // idle | running | etc.
86
+ scopeLevel: item.workflow?.scope_level, // account | site
87
+ scopeId: item.workflow?.scope_id,
88
+ siteName: item.workflow?.site_name,
89
+ createdAt: item.workflow?.created_at,
90
+ updatedAt: item.workflow?.updated_at,
91
+ versionCount: item.workflow?.version_count,
92
+ triggerTypes: [...new Set((item.actions || [])
93
+ .filter(a => a.type?.endsWith('_trigger'))
94
+ .map(a => a.type))],
95
+ actionTypes: [...new Set((item.actions || [])
96
+ .filter(a => !a.type?.endsWith('_trigger'))
97
+ .map(a => a.type))],
98
+ integrationIds: [...new Set((item.actions || [])
99
+ .filter(a => a.integration_id)
100
+ .map(a => a.integration_id))],
101
+ }));
102
+ return JSON.stringify({
103
+ workflows: items,
104
+ totalItems: result?.pagination?.totalItems ?? null,
105
+ skip,
106
+ limit: params.limit,
107
+ }, null, 2);
108
+ },
109
+ },
110
+
111
+ // ─── ha_get_workflow ──────────────────────────────────────────────────────
112
+ {
113
+ name: 'ha_get_workflow',
114
+ description: `Get a single Hyperautomation workflow by workflowId and revisionId. The revisionId (= workflow.version_id) is returned by ha_list_workflows — always pass both IDs for a direct fetch. If revisionId is omitted, the tool will scan the first page of workflows to find the current revision, which is slower. Returns the full workflow object including trigger configuration, action steps, integration dependencies, scope, and version metadata.`,
115
+ inputSchema: {
116
+ type: 'object',
117
+ properties: {
118
+ workflowId: {
119
+ type: 'string',
120
+ description: 'Hyperautomation workflow UUID (from ha_list_workflows).',
121
+ },
122
+ revisionId: {
123
+ type: 'string',
124
+ description: 'Workflow version/revision UUID (= workflow.version_id from ha_list_workflows). Provide this to avoid an extra list call.',
125
+ },
126
+ },
127
+ required: ['workflowId'],
128
+ },
129
+ async handler({ workflowId, revisionId }) {
130
+ let resolvedRevisionId = revisionId;
131
+
132
+ // If revisionId not supplied, resolve it from the list endpoint.
133
+ if (!resolvedRevisionId) {
134
+ // The list endpoint does not support filtering by workflowId, so we scan
135
+ // up to 200 workflows sorted by updated_at desc (most recently touched first).
136
+ // Confirmed: GET /workflows/single/{id} alone returns 404; revisionId is required.
137
+ const listResult = await apiGet(`${HA_BASE}/workflows`, {
138
+ limit: 200,
139
+ skip: 0,
140
+ sortBy: 'updated_at',
141
+ sortOrder: 'desc',
142
+ });
143
+ const found = (listResult?.data || []).find(item => item.id === workflowId);
144
+ if (!found) {
145
+ return JSON.stringify({
146
+ error: `Workflow ${workflowId} not found in the first 200 results. ` +
147
+ 'Provide revisionId directly (from ha_list_workflows) for an exact fetch.',
148
+ });
149
+ }
150
+ resolvedRevisionId = found.workflow?.version_id;
151
+ }
152
+
153
+ // Confirmed endpoint: GET /workflows/single/{workflowId}/{revisionId}
154
+ const result = await apiGet(`${HA_BASE}/workflows/single/${workflowId}/${resolvedRevisionId}`);
155
+ return JSON.stringify(result, null, 2);
156
+ },
157
+ },
158
+
159
+ // ─── ha_archive_workflow ──────────────────────────────────────────────────
160
+ {
161
+ name: 'ha_archive_workflow',
162
+ description: `Archive (soft-delete) one or more Hyperautomation workflows. Archive is the console's delete operation — the workflow is removed from the active list but the underlying data is retained. Equivalent to clicking Delete in the Hyperautomation UI. Requires Hyper Automate.write permission. Returns the API response (200 OK on success). This action is NOT easily reversible — confirm with the user before calling.`,
163
+ inputSchema: {
164
+ type: 'object',
165
+ properties: {
166
+ workflowIds: {
167
+ type: 'array',
168
+ items: { type: 'string' },
169
+ description: 'One or more workflow UUIDs to archive (from ha_list_workflows).',
170
+ },
171
+ },
172
+ required: ['workflowIds'],
173
+ },
174
+ async handler({ workflowIds }) {
175
+ if (!Array.isArray(workflowIds) || workflowIds.length === 0) {
176
+ return JSON.stringify({ error: 'workflowIds must be a non-empty array of UUIDs.' });
177
+ }
178
+ // Confirmed: POST /workflows/archive with IDs in request body.
179
+ // This is the backend call behind the UI Delete button.
180
+ const result = await apiPost(`${HA_BASE}/workflows/archive`, { workflowIds });
181
+ return JSON.stringify(result, null, 2);
182
+ },
183
+ },
184
+
185
+ // ─── ha_import_workflow ───────────────────────────────────────────────────
186
+ {
187
+ name: 'ha_import_workflow',
188
+ description: `Import a Hyperautomation workflow JSON into the SentinelOne console. The workflow JSON must follow the Hyperautomation schema (use the sentinelone-hyperautomation skill to generate valid JSON). Integration-backed actions (type=http_request with an integration_id) require pre-configured connections in Hyperautomation > Integrations before the workflow will run. The import API validates schema but cannot check if integrations are configured. Returns the created workflow ID on success. Requires Hyper Automate.write permission.`,
189
+ inputSchema: {
190
+ type: 'object',
191
+ properties: {
192
+ workflowJson: {
193
+ type: 'string',
194
+ description: 'Full Hyperautomation workflow JSON as a string. Must be valid Hyperautomation schema. Generate this using the sentinelone-hyperautomation skill.',
195
+ },
196
+ },
197
+ required: ['workflowJson'],
198
+ },
199
+ async handler({ workflowJson }) {
200
+ let parsed;
201
+ try {
202
+ parsed = JSON.parse(workflowJson);
203
+ } catch (e) {
204
+ return JSON.stringify({ error: `Invalid JSON: ${e.message}` });
205
+ }
206
+ // Path confirmed working during backtest (returns 403 without write permission).
207
+ // /v1 equivalent not yet captured — keeping /public path until verified.
208
+ const result = await apiPost(`${HA_PUBLIC}/workflow-import-export/import`, { data: parsed });
209
+ return JSON.stringify(result, null, 2);
210
+ },
211
+ },
212
+
213
+ // ─── ha_export_workflow ───────────────────────────────────────────────────
214
+ {
215
+ name: 'ha_export_workflow',
216
+ description: `Export all Hyperautomation workflows as a ZIP archive. Returns metadata about the ZIP (size, content-type) plus the first 200 bytes of the base64-encoded content. NOTE: The export API (confirmed via live backtest) returns ALL workflows — there is no per-workflow filter. Use ha_get_workflow to read a specific workflow's JSON definition instead. Export/import endpoints were not captured in the v1 network trace; this tool uses the confirmed /public path.`,
217
+ inputSchema: {
218
+ type: 'object',
219
+ properties: {},
220
+ required: [],
221
+ },
222
+ async handler() {
223
+ // Export path confirmed working during backtest at /public path.
224
+ // GET returns binary ZIP of ALL workflows; POST returns 405.
225
+ // Per-workflow filter is not supported by this API version.
226
+ const { getCreds } = await import('../lib/credentials.js');
227
+ const creds = getCreds();
228
+ const base = creds.S1_CONSOLE_URL.replace(/\/+$/, '');
229
+ const tok = creds.S1_CONSOLE_API_TOKEN;
230
+ const url = `${base}${HA_PUBLIC}/workflow-import-export/export`;
231
+
232
+ const res = await fetch(url, {
233
+ method: 'GET',
234
+ headers: { Authorization: `ApiToken ${tok}` },
235
+ });
236
+ if (!res.ok) {
237
+ const text = await res.text();
238
+ throw new Error(`ha_export_workflow → ${res.status}: ${text}`);
239
+ }
240
+ const buf = await res.arrayBuffer();
241
+ const base64 = Buffer.from(buf).toString('base64');
242
+ return JSON.stringify({
243
+ note: 'Export returns all workflows as a binary ZIP. Per-workflow filtering is not supported.',
244
+ contentType: res.headers.get('Content-Type') || 'application/zip',
245
+ sizeBytes: buf.byteLength,
246
+ base64Preview: base64.slice(0, 200) + '… [truncated; full ZIP in buffer]',
247
+ }, null, 2);
248
+ },
249
+ },
250
+ ];
@@ -0,0 +1,307 @@
1
+ /**
2
+ * Management Console API tools — sentinelone-mgmt-console-api skill
3
+ *
4
+ * Tools:
5
+ * s1_api_get Generic GET against S1 Mgmt Console REST API
6
+ * s1_api_post Generic POST against S1 Mgmt Console REST API
7
+ * s1_api_put Generic PUT against S1 Mgmt Console REST API
8
+ * s1_api_delete Generic DELETE against S1 Mgmt Console REST API
9
+ * s1_api_patch Generic PATCH against S1 Mgmt Console REST API
10
+ * purple_ai_alert_summary Get a Purple AI natural-language summary for a specific UAM alert
11
+ * uam_list_alerts List/search UAM alerts via GraphQL
12
+ * uam_get_alert Get full alert details (notes, history)
13
+ * uam_add_note Add analyst note to an alert
14
+ * uam_set_status Update alert status (NEW, IN_PROGRESS, RESOLVED)
15
+ *
16
+ * REMOVED (2026-05-03 — confirmed non-functional for API tokens):
17
+ * purple_ai_query — requires browser-session teamToken from /sdl/v2/graphql that
18
+ * API-token service accounts never obtain. Use Purple MCP instead.
19
+ * purple_ai_investigate — same root cause (SERVICE_ERROR). Use Purple MCP instead.
20
+ */
21
+
22
+ import { apiGet, apiPost, apiPut, apiDelete, apiPatch, purpleAlertSummary, uamListAlerts, uamGetAlert, uamAddNote, uamSetStatus } from '../lib/s1.js';
23
+
24
+ export const tools = [
25
+ // ─── s1_api_get ───────────────────────────────────────────────────────────
26
+ {
27
+ name: 's1_api_get',
28
+ description: `Generic GET request to the SentinelOne Management Console REST API (v2.1). Use for ALL read operations: listing, counting, and exporting. The S1 API uses GET for every read — listing, counting, and exporting are always GET, never POST. The path should start with /web/api/v2.1/. Returns raw JSON response. For paginated endpoints, use the cursor or skip/limit params. Count examples: path="/web/api/v2.1/agents/count" returns {"data":{"total":N}}; path="/web/api/v2.1/threats" params={"countOnly":true} returns pagination.totalItems. Export example: path="/web/api/v2.1/threats/export" (no extra params). Get agents by IDs: path="/web/api/v2.1/agents" params={"ids":"<id1>,<id2>"} (comma-separated query param).`,
29
+ inputSchema: {
30
+ type: 'object',
31
+ properties: {
32
+ path: {
33
+ type: 'string',
34
+ description: 'API path starting with /web/api/v2.1/, e.g. "/web/api/v2.1/agents".',
35
+ },
36
+ params: {
37
+ type: 'object',
38
+ description: 'Query string parameters as key-value pairs, e.g. {"limit": 20, "sortBy": "createdAt"}.',
39
+ additionalProperties: true,
40
+ },
41
+ },
42
+ required: ['path'],
43
+ },
44
+ async handler({ path, params = {} }) {
45
+ const result = await apiGet(path, params);
46
+ return JSON.stringify(result, null, 2);
47
+ },
48
+ },
49
+
50
+ // ─── s1_api_post ──────────────────────────────────────────────────────────
51
+ {
52
+ name: 's1_api_post',
53
+ description: `Generic POST request to the SentinelOne Management Console REST API (v2.1). Use ONLY for write and action operations: create IOC, isolate agent, add exclusion, create custom detection rule, trigger RemoteOps, etc. The path should start with /web/api/v2.1/. NEVER use POST for listing, counting, or exporting — all reads are GET. POST to a read path returns HTTP 404 because the path does not exist in the API (e.g. POST /agents/ids, POST /threats/summary, POST /export/threats are all wrong). Before calling, verify the path exists with: python3 scripts/search_endpoints.py "<keyword>". The body is NOT auto-wrapped — pass the complete envelope, e.g. {"data": {...}, "filter": {...}}.`,
54
+ inputSchema: {
55
+ type: 'object',
56
+ properties: {
57
+ path: {
58
+ type: 'string',
59
+ description: 'API path starting with /web/api/v2.1/, e.g. "/web/api/v2.1/threats/mark-as-threats".',
60
+ },
61
+ body: {
62
+ type: 'object',
63
+ description: 'Request body as JSON. For most S1 endpoints use the {"data": {...}, "filter": {...}} envelope. Pass the complete body.',
64
+ additionalProperties: true,
65
+ },
66
+ },
67
+ required: ['path', 'body'],
68
+ },
69
+ async handler({ path, body }) {
70
+ const result = await apiPost(path, body);
71
+ return JSON.stringify(result, null, 2);
72
+ },
73
+ },
74
+
75
+ // ─── s1_api_put ───────────────────────────────────────────────────────────
76
+ {
77
+ name: 's1_api_put',
78
+ description: `Generic PUT request to the SentinelOne Management Console REST API (v2.1). Use for full-replacement updates: update agent policies, replace exclusion rules, set system configuration, update firewall/device control rules, update group settings, etc. The path should start with /web/api/v2.1/. The body replaces the resource in full — include all required fields, not just the changed ones. Consult the swagger reference at references/tags/ in the sentinelone-mgmt-console-api skill before calling. Examples: path="/web/api/v2.1/accounts/{id}/policy" body={"data":{...policy fields...}}, path="/web/api/v2.1/system/configuration" body={"data":{...}}.`,
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
82
+ path: {
83
+ type: 'string',
84
+ description: 'API path starting with /web/api/v2.1/, e.g. "/web/api/v2.1/accounts/123/policy".',
85
+ },
86
+ body: {
87
+ type: 'object',
88
+ description: 'Full replacement body as JSON. Most S1 PUT endpoints use {"data": {...}} envelope. Include all required fields for the resource.',
89
+ additionalProperties: true,
90
+ },
91
+ },
92
+ required: ['path', 'body'],
93
+ },
94
+ async handler({ path, body }) {
95
+ const result = await apiPut(path, body);
96
+ return JSON.stringify(result, null, 2);
97
+ },
98
+ },
99
+
100
+ // ─── s1_api_delete ────────────────────────────────────────────────────────
101
+ {
102
+ name: 's1_api_delete',
103
+ description: `Generic DELETE request to the SentinelOne Management Console REST API (v2.1). Use for delete operations: delete IOCs (DELETE /web/api/v2.1/threat-intelligence/iocs), delete unified exclusions, delete custom detection rules, delete remote scripts, delete firewall/device control rules, delete tags, etc. The path should start with /web/api/v2.1/. Many S1 DELETE endpoints accept a filter body (e.g. IOCDeleteSchema uses accountId + one other field) — pass it as the body param. Some accept query params only (body can be omitted). Consult the swagger reference at references/tags/ in the sentinelone-mgmt-console-api skill for the exact filter schema before calling. WARNING: deletions are irreversible. Confirm the target ID/filter before executing.`,
104
+ inputSchema: {
105
+ type: 'object',
106
+ properties: {
107
+ path: {
108
+ type: 'string',
109
+ description: 'API path starting with /web/api/v2.1/, e.g. "/web/api/v2.1/threat-intelligence/iocs".',
110
+ },
111
+ body: {
112
+ type: 'object',
113
+ description: 'Optional request body as JSON. Required for filter-based deletes (e.g. IOC delete requires {"filter": {"accountId": "...", "uuids": [...]}}). Omit for ID-in-path deletes.',
114
+ additionalProperties: true,
115
+ },
116
+ },
117
+ required: ['path'],
118
+ },
119
+ async handler({ path, body = {} }) {
120
+ const result = await apiDelete(path, body);
121
+ return JSON.stringify(result, null, 2);
122
+ },
123
+ },
124
+
125
+ // ─── s1_api_patch ─────────────────────────────────────────────────────────
126
+ {
127
+ name: 's1_api_patch',
128
+ description: `Generic PATCH request to the SentinelOne Management Console REST API (v2.1). Use for partial updates where only specific fields need to change without replacing the full resource. Less common in the S1 API than PUT, but used by some endpoints for partial config updates and field-level changes. The path should start with /web/api/v2.1/. Pass only the fields to change in the body. Consult the swagger reference at references/tags/ in the sentinelone-mgmt-console-api skill to confirm whether a given endpoint expects PUT (full replace) or PATCH (partial update).`,
129
+ inputSchema: {
130
+ type: 'object',
131
+ properties: {
132
+ path: {
133
+ type: 'string',
134
+ description: 'API path starting with /web/api/v2.1/.',
135
+ },
136
+ body: {
137
+ type: 'object',
138
+ description: 'Partial update body as JSON. Include only the fields to change.',
139
+ additionalProperties: true,
140
+ },
141
+ },
142
+ required: ['path', 'body'],
143
+ },
144
+ async handler({ path, body }) {
145
+ const result = await apiPatch(path, body);
146
+ return JSON.stringify(result, null, 2);
147
+ },
148
+ },
149
+
150
+ // ─── purple_ai_alert_summary ──────────────────────────────────────────────
151
+ {
152
+ name: 'purple_ai_alert_summary',
153
+ description: `Get a Purple AI natural-language summary for a specific UAM alert. Calls purpleAlertSummary (operation AlertSummary) at /web/api/v2.1/graphql with the full OCSF alert JSON. This is what populates the "Purple AI" card in the alert detail panel. Synchronous (no polling required). Returns { token, summary }. Use uam_get_alert first to retrieve the raw alert data, then pass its OCSF JSON here.`,
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ alertJson: {
158
+ type: 'string',
159
+ description: 'The full alert as a JSON string (OCSF format, as returned by uam_get_alert or the GetAlert GraphQL query). Pass the entire alert object serialised to a string.',
160
+ },
161
+ },
162
+ required: ['alertJson'],
163
+ },
164
+ async handler({ alertJson }) {
165
+ const result = await purpleAlertSummary(alertJson);
166
+ return JSON.stringify(result, null, 2);
167
+ },
168
+ },
169
+
170
+ // ─── uam_list_alerts ──────────────────────────────────────────────────────
171
+ {
172
+ name: 'uam_list_alerts',
173
+ description: `List UAM (Unified Alert Management) alerts via GraphQL. The PRIMARY alert API in S1 — covers all alert types (EDR, STAR, cloud, identity, third-party). Uses the correct FilterInput schema: dateTimeRange { start, end } for time windows (epoch ms). USE THIS instead of Purple MCP search_alerts for time-scoped searches — the Purple MCP sends date_range (snake_case) which UAM rejects; this tool uses dateTimeRange (the actual schema field). Convenience params (status, severity, startTime, endTime) build FilterInputs automatically. For deeper analysis, follow up with uam_get_alert.`,
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ first: {
178
+ type: 'number',
179
+ description: 'Number of alerts to fetch (default 20, max 100).',
180
+ default: 20,
181
+ },
182
+ after: {
183
+ type: 'string',
184
+ description: 'Pagination cursor from a prior call\'s pageInfo.endCursor. Omit for first page.',
185
+ },
186
+ viewType: {
187
+ type: 'string',
188
+ description: 'Alert view scope.',
189
+ enum: ['ALL', 'ENDPOINT', 'IDENTITY', 'STAR', 'CUSTOM_ALERTS', 'CLOUD', 'THIRD_PARTY'],
190
+ default: 'ALL',
191
+ },
192
+ status: {
193
+ type: 'string',
194
+ description: 'Filter by status. Uses stringEqual FilterInput. Valid values (confirmed against live tenant): "NEW", "IN_PROGRESS", "RESOLVED". "OPEN" is NOT a valid value and silently returns 0 results. "FALSE_POSITIVE" is an analystVerdict field, not a status — use uam_set_status to set analystVerdict separately.',
195
+ },
196
+ severity: {
197
+ type: 'string',
198
+ description: 'Filter by severity. Uses stringEqual FilterInput. e.g. "CRITICAL", "HIGH", "MEDIUM", "LOW".',
199
+ },
200
+ detectionProduct: {
201
+ type: 'string',
202
+ description: 'Filter by detection product. e.g. "EDR", "STAR", "CLOUD".',
203
+ },
204
+ searchText: {
205
+ type: 'string',
206
+ description: 'Full-text search across alert fields.',
207
+ },
208
+ startTime: {
209
+ type: 'string',
210
+ description: 'Start of time window. ISO-8601 string ("2026-05-03T07:32:00Z") or epoch milliseconds as string. Builds a dateTimeRange FilterInput on detectedAt using { start, end } — the actual UAM schema fields confirmed by introspection.',
211
+ },
212
+ endTime: {
213
+ type: 'string',
214
+ description: 'End of time window. ISO-8601 string or epoch ms. Defaults to now when startTime is provided.',
215
+ },
216
+ },
217
+ required: [],
218
+ },
219
+ async handler({ first = 20, after, viewType = 'ALL', status, severity, detectionProduct, searchText, startTime, endTime } = {}) {
220
+ // Convert string epoch ms to numbers if needed
221
+ const parseTime = (v) => {
222
+ if (!v) return null;
223
+ const n = Number(v);
224
+ return isNaN(n) ? v : n; // if numeric string, use as epoch ms; otherwise pass as ISO
225
+ };
226
+ const result = await uamListAlerts({
227
+ first, after, viewType,
228
+ status: status || null,
229
+ severity: severity || null,
230
+ detectionProduct: detectionProduct || null,
231
+ searchText: searchText || null,
232
+ startTime: parseTime(startTime),
233
+ endTime: parseTime(endTime),
234
+ });
235
+ return JSON.stringify(result, null, 2);
236
+ },
237
+ },
238
+
239
+ // ─── uam_get_alert ────────────────────────────────────────────────────────
240
+ {
241
+ name: 'uam_get_alert',
242
+ description: `Get full details for a specific UAM alert including analyst notes. ALWAYS call this before making a verdict — notes may contain MDR verdicts (False Positive / Benign / Resolved) that take precedence over detection engine severity. Returns alert fields plus a notes array from alertNotes query.`,
243
+ inputSchema: {
244
+ type: 'object',
245
+ properties: {
246
+ alertId: {
247
+ type: 'string',
248
+ description: 'The UAM alert ID (string UUID, from uam_list_alerts results).',
249
+ },
250
+ },
251
+ required: ['alertId'],
252
+ },
253
+ async handler({ alertId }) {
254
+ const result = await uamGetAlert(alertId);
255
+ return JSON.stringify(result, null, 2);
256
+ },
257
+ },
258
+
259
+ // ─── uam_add_note ─────────────────────────────────────────────────────────
260
+ {
261
+ name: 'uam_add_note',
262
+ description: `Add an analyst note to a UAM alert. Use to document investigation findings, intermediate verdicts, IOC enrichment results, or escalation decisions. Notes are visible to all analysts and MDR. Best practice: include a timestamp-like prefix and cite the evidence inline (e.g. "VT: 12/72 malicious on hash abc123; cross-correlated with FortiGate BLOCK events on same dst IP").`,
263
+ inputSchema: {
264
+ type: 'object',
265
+ properties: {
266
+ alertId: {
267
+ type: 'string',
268
+ description: 'The UAM alert ID.',
269
+ },
270
+ note: {
271
+ type: 'string',
272
+ description: 'Note text. Cite evidence inline. Avoid vague statements — be specific about what queries were run, what IOCs were checked, and what the results were.',
273
+ },
274
+ },
275
+ required: ['alertId', 'note'],
276
+ },
277
+ async handler({ alertId, note }) {
278
+ const result = await uamAddNote(alertId, note);
279
+ return JSON.stringify(result, null, 2);
280
+ },
281
+ },
282
+
283
+ // ─── uam_set_status ───────────────────────────────────────────────────────
284
+ {
285
+ name: 'uam_set_status',
286
+ description: `Update the status of a UAM alert. Valid values: NEW (reopen), IN_PROGRESS (actively investigating), RESOLVED (threat contained and remediated). Note: FALSE_POSITIVE is NOT a status value on this API — it is an analystVerdict. To mark an alert as a false positive, add a note explaining why and set status to RESOLVED. Always add a note via uam_add_note before closing an alert.`,
287
+ inputSchema: {
288
+ type: 'object',
289
+ properties: {
290
+ alertId: {
291
+ type: 'string',
292
+ description: 'The UAM alert ID.',
293
+ },
294
+ status: {
295
+ type: 'string',
296
+ description: 'New status. Must be one of the confirmed enum values.',
297
+ enum: ['NEW', 'IN_PROGRESS', 'RESOLVED'],
298
+ },
299
+ },
300
+ required: ['alertId', 'status'],
301
+ },
302
+ async handler({ alertId, status }) {
303
+ const result = await uamSetStatus(alertId, status);
304
+ return JSON.stringify(result, null, 2);
305
+ },
306
+ },
307
+ ];