@pmoses-s1/s1-secops-mcp 1.3.6 → 1.3.7

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.7
4
+
5
+ Minor, not patch: a tool is gone and the log-ingest credential changed. Both
6
+ break an existing caller, so the version says so.
7
+
8
+ ### Log ingest needs an SDL Log Write Key
9
+
10
+ `hec_ingest` now authenticates with **`S1_HEC_TOKEN`**, an SDL Log Write Key,
11
+ and no longer sends an `S1-Scope` header. The Management Console API token is
12
+ refused by the event collector. Measured on a live tenant, same request, same
13
+ endpoint:
14
+
15
+ write key -> HTTP 200 {"text":"Success","code":0}
16
+ console token -> HTTP 400 {"text":"Missing S1-Scope header","code":5}
17
+
18
+ The key is minted per account or site and writes only there, so it fixes the
19
+ destination and there is nothing for a scope header to override. Mint it at
20
+ Console > Singularity Data Lake > API Keys > Log Write Key; no API creates one.
21
+ `S1_HEC_TOKEN` is optional, because only raw log ingest needs it, and it is named
22
+ to match the deployer repos so one value covers both.
23
+
24
+ UAM alert ingest and IOCs are NOT affected. They still use
25
+ `S1_CONSOLE_API_TOKEN`, and `/v1/alerts` still requires `S1-Scope`.
26
+
27
+ ### Indicators ride inside the alert
28
+
29
+ **`uam_post_indicators` is removed** (32 tools -> 31). Indicators can no longer
30
+ be ingested on their own: `/v1/indicators` refuses the console user token and the
31
+ Log Write Key alike, so no credential can drive it. They now travel inline in
32
+ `finding_info.related_events[]` on a single `POST /v1/alerts`, which is also what
33
+ the console Indicators tab reads.
34
+
35
+ `uam_ingest_alert` posts once, inline. The `inline` parameter is still accepted
36
+ so existing callers do not crash, but it is forced to true and a false value
37
+ comes back with a note saying it was ignored, rather than quietly doing something
38
+ different. The old two-call flow, its ~3s sleep and its ordering contract are
39
+ deleted rather than left throwing: a code path that can only fail invites callers
40
+ to keep it alive.
41
+
42
+ Verified end to end on a live tenant: one alert carrying four indicators inline
43
+ in a single POST surfaced in UAM with all four rendered and every uid matching.
44
+
45
+ ### Also
46
+
47
+ - `Indicator` in the UAM GraphQL schema has fields `type, uid, title,
48
+ description, message, severity`. There is no `name` and no `category`;
49
+ querying either returns `FieldUndefined`. Examples across the docs used them.
50
+ - `sdlToken`'s missing-credential test now runs in a child process. It deleted an
51
+ env var, but credentials are resolved once at import and fall back to a file,
52
+ so the exception it asserted could only be thrown on a machine with no
53
+ credentials at all. It passed in CI for exactly that reason.
54
+
3
55
  ## 1.3.6 - 2026-08-17
4
56
 
5
57
  Behaviour change, found by deploying to a real site and not being able to see the
@@ -131,7 +183,7 @@ were not wrapped at all. **26 tools → 32.**
131
183
 
132
184
  - `sdl-api/references/config-file-graphql.md` claimed the `s1-scope` header was
133
185
  "ignored, not rejected" on `/sdl/v2/graphql`. **That was wrong.** Measured on
134
- `usea1-purple`, same token and query: `configFiles` returned 113 files at
186
+ `<console>`, same token and query: `configFiles` returned 113 files at
135
187
  account scope and 4 at a site scope. Config listings and dashboard reads are
136
188
  scope-FILTERED, so a dropped header changes which objects appear to exist.
137
189
  `auth_and_limits.md` corrected to match.
@@ -243,7 +295,7 @@ Config-file operations move from the legacy REST endpoints to GraphQL. Tool coun
243
295
  ### Fixed
244
296
 
245
297
  - **`sdl_list_files` no longer returns an incomplete listing.** The REST `/sdl/api/listFiles`
246
- endpoint omits every udoId-addressed dashboard. Measured live on `usea1-purple`: REST returned
298
+ endpoint omits every udoId-addressed dashboard. Measured live on `<console>`: REST returned
247
299
  1,914 paths against `configFiles`' 2,264, a 350-file gap consisting entirely of `/dashboards/`
248
300
  files that carry a `udoId`. REST `getFile` on any of them returns `success/noSuchFile`. The
249
301
  practical impact was a false negative: a dashboard that existed in the console was reported as
package/README.md CHANGED
@@ -24,6 +24,7 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
24
24
  | Mgmt Console | `s1_api_post` | mgmt-console-api |
25
25
  | Mgmt Console | `s1_api_put` | mgmt-console-api |
26
26
  | Mgmt Console | `uam_add_note` | mgmt-console-api |
27
+ | Mgmt Console | `uam_available_actions` | mgmt-console-api |
27
28
  | Mgmt Console | `uam_get_alert` | mgmt-console-api |
28
29
  | Mgmt Console | `uam_list_alerts` | mgmt-console-api |
29
30
  | Mgmt Console | `uam_set_status` | mgmt-console-api |
@@ -45,7 +46,6 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
45
46
  | Hyperautomation | `ha_list_workflows` | hyperautomation |
46
47
  | UAM Ingest | `uam_ingest_alert` | mgmt-console-api (UAM Alert Interface) |
47
48
  | UAM Ingest | `uam_post_alert` | mgmt-console-api (UAM Alert Interface) |
48
- | UAM Ingest | `uam_post_indicators` | mgmt-console-api (UAM Alert Interface) |
49
49
  <!-- END AUTO-GENERATED TOOLS TABLE -->
50
50
 
51
51
  **2 resources:**
@@ -73,7 +73,7 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
73
73
  "mcpServers": {
74
74
  "s1-secops-mcp": {
75
75
  "command": "npx",
76
- "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.6"],
76
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.7"],
77
77
  "env": {
78
78
  "S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
79
79
  "S1_CONSOLE_API_TOKEN": "eyJ...",
@@ -136,7 +136,9 @@ Credential keys, where to get each one, and the two token types are documented c
136
136
 
137
137
  `S1_CONSOLE_URL` and `S1_CONSOLE_API_TOKEN` are sufficient for the PowerQuery, Mgmt Console REST, Purple AI summary, UAM, Hyperautomation, and SDL config-file tools (22 of the 26).
138
138
 
139
- `S1_HEC_INGEST_URL` is **required** for the three UAM Ingest tools (`uam_ingest_alert`, `uam_post_indicators`, `uam_post_alert`) and for `hec_ingest`, the only four tools that need it. Without it those tools error at call time; the rest still work.
139
+ `S1_HEC_INGEST_URL` is **required** for the two UAM Ingest tools (`uam_ingest_alert`, `uam_post_alert`) and for `hec_ingest`, the only three tools that need it. Without it those tools error at call time; the rest still work.
140
+
141
+ `hec_ingest` additionally needs **`S1_HEC_TOKEN`**, an SDL Log Write Key. It is a different credential from the console API token, which the event collector refuses outright, and no API mints one: Console > Singularity Data Lake > API Keys > Log Write Key. The key is issued for a single account or site and writes only there, so it fixes the destination and there is no scope header to override it. UAM alert ingest and IOCs are unaffected and still use `S1_CONSOLE_API_TOKEN`.
140
142
 
141
143
  The SDL config-file tools (`sdl_list_files`, `sdl_get_file`, `sdl_put_file`, `sdl_delete_file`) are authorised by `S1_CONSOLE_API_TOKEN` against `POST <console>/sdl/v2/graphql`. The scoped SDL keys (`SDL_CONFIG_READ_KEY`, `SDL_CONFIG_WRITE_KEY`, `SDL_LOG_READ_KEY`, `SDL_LOG_WRITE_KEY`, `SDL_XDR_URL`) are retired and are no longer read.
142
144
 
@@ -144,7 +146,8 @@ The SDL config-file tools (`sdl_list_files`, `sdl_get_file`, `sdl_put_file`, `sd
144
146
  |----------|-------------|--------------|
145
147
  | `S1_CONSOLE_URL` | Console URL, e.g. `https://usea1-acme.sentinelone.net` | All Mgmt + PowerQuery + SDL tools |
146
148
  | `S1_CONSOLE_API_TOKEN` | Mgmt Console API token (Settings → Users → Service Users) | All Mgmt + PowerQuery + UAM + SDL config-file tools |
147
- | `S1_HEC_INGEST_URL` | HEC ingest host, e.g. `https://ingest.us1.sentinelone.net` | `uam_ingest_alert`, `uam_post_indicators`, `uam_post_alert`, `hec_ingest` |
149
+ | `S1_HEC_INGEST_URL` | Ingest host, e.g. `https://ingest.us1.sentinelone.net` | `uam_ingest_alert`, `uam_post_alert`, `hec_ingest` |
150
+ | `S1_HEC_TOKEN` | SDL Log Write Key, scoped to one account or site. Optional; only raw log ingest needs it. Same variable name as the deployer repos. | `hec_ingest` |
148
151
 
149
152
  ### Credential resolution order (highest priority wins)
150
153
 
package/deploy/README.md CHANGED
@@ -57,7 +57,7 @@ Or, equivalently, by package name without the install:
57
57
  "mcpServers": {
58
58
  "s1-secops-mcp": {
59
59
  "command": "npx",
60
- "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.6"]
60
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.7"]
61
61
  }
62
62
  }
63
63
  }
@@ -376,7 +376,7 @@ Both block the W+X memory mappings V8 needs to JIT JavaScript. Adding them cause
376
376
 
377
377
  These are supported but not first-class:
378
378
 
379
- - **Docker / docker-compose.** Not shipped in this version. The single-file Node binary doesn't need it. If you want a container, the install is `FROM node:20-alpine` + `RUN npm install -g @pmoses-s1/s1-secops-mcp@1.3.6` + `CMD ["s1-secops-mcp", "--transport", "http", "--host", "0.0.0.0"]`. Mount creds at `/etc/s1-secops-mcp/credentials.json` and tokens at `/etc/s1-secops-mcp/bearer-tokens.json`.
379
+ - **Docker / docker-compose.** Not shipped in this version. The single-file Node binary doesn't need it. If you want a container, the install is `FROM node:20-alpine` + `RUN npm install -g @pmoses-s1/s1-secops-mcp@1.3.7` + `CMD ["s1-secops-mcp", "--transport", "http", "--host", "0.0.0.0"]`. Mount creds at `/etc/s1-secops-mcp/credentials.json` and tokens at `/etc/s1-secops-mcp/bearer-tokens.json`.
380
380
 
381
381
  - **External bridge (`supergateway`, `mcp-proxy`).** Pre-1.1.0 deployments used these to wrap the stdio-only server. They still work; this server's native HTTP mode is functionally equivalent and removes the extra process. Prefer native unless you have a specific reason.
382
382
 
package/index.js CHANGED
@@ -108,7 +108,7 @@ ENVIRONMENT
108
108
  S1_CONSOLE_URL Console URL, e.g. https://usea1-acme.sentinelone.net
109
109
  S1_CONSOLE_API_TOKEN Mgmt Console API token. Required for most tools.
110
110
  S1_HEC_INGEST_URL HEC ingest host. Required for uam_ingest_alert,
111
- uam_post_indicators, uam_post_alert.
111
+ uam_post_alert.
112
112
  S1_CREDS_FILE Explicit path to a credentials.json file.
113
113
  Highest priority for credential resolution.
114
114
  S1_CLAUDE_MD_PATH Absolute path to CLAUDE.md for the soc_analyst
@@ -106,6 +106,19 @@ export function getCreds() {
106
106
  S1_CONSOLE_URL: e('S1_CONSOLE_URL'),
107
107
  S1_CONSOLE_API_TOKEN: e('S1_CONSOLE_API_TOKEN') || e('S1_API_TOKEN'),
108
108
  S1_HEC_INGEST_URL: e('S1_HEC_INGEST_URL'),
109
+ // SDL Log Write Key, used ONLY for log ingest over the event collector.
110
+ // Optional: every other tool authenticates with S1_CONSOLE_API_TOKEN, and a
111
+ // deployment that never ingests logs does not need this at all.
112
+ //
113
+ // It is a DIFFERENT credential, not an alias. The collector refuses a
114
+ // console user token, and the key is minted for one account or site and
115
+ // writes only there, so it also fixes the destination: there is no scope
116
+ // header to override. Mint it at Console > Singularity Data Lake >
117
+ // API Keys > Log Write Key. No API creates one.
118
+ //
119
+ // Named to match the deployer repos, which already ship this variable, so
120
+ // one value can be pasted across all of them.
121
+ S1_HEC_TOKEN: e('S1_HEC_TOKEN'),
109
122
  // Default S1-Scope for SDL requests: "<accountId>" for account scope or
110
123
  // "<accountId>:<siteId>" for site scope. Optional. Per-call scope arguments
111
124
  // override it; passing scope:null suppresses it entirely. SDL reads are
package/lib/hec.js CHANGED
@@ -10,8 +10,16 @@
10
10
  * Host : S1_HEC_INGEST_URL (e.g. https://ingest.us1.sentinelone.net)
11
11
  * Endpoints : /services/collector/raw (raw text, recommended for logs)
12
12
  * /services/collector/event (structured JSON)
13
- * Auth : Authorization: Bearer <S1_CONSOLE_API_TOKEN> (the same Management Console API token the other tools use)
14
- * Scope : S1-Scope header is REQUIRED (accountId or accountId:siteId). Without it HEC returns 400 "Missing S1-Scope header".
13
+ * Auth : Authorization: Bearer <S1_HEC_TOKEN>, an SDL Log Write Key. NOT the
14
+ * Management Console API token: the collector refuses a user token.
15
+ * Mint one at Console > Singularity Data Lake > API Keys > Log Write Key;
16
+ * no API creates one.
17
+ * Scope : NONE. A Log Write Key is minted for one account or site and writes only
18
+ * there, so the key itself fixes the destination and the S1-Scope header is
19
+ * neither required nor honoured. Measured on a live tenant: the write key
20
+ * returns 200 with no S1-Scope header at all, while the console token on the
21
+ * same request returns 400 "Missing S1-Scope header". To write somewhere
22
+ * else, use a key minted for that scope.
15
23
  * Parser : ?sourcetype=<parserName> query param. Other query params become fields in the UI.
16
24
  * Pre-parsed: /event with ?isParsed=true indexes already-structured JSON fields directly, with no SDL parser.
17
25
  * Compress : optional "Content-Encoding: gzip" (or zstd), recommended, lowers egress cost.
@@ -36,9 +44,15 @@ function hecBase() {
36
44
  }
37
45
 
38
46
  function hecToken() {
39
- const tok = getCreds().S1_CONSOLE_API_TOKEN;
47
+ const tok = getCreds().S1_HEC_TOKEN;
40
48
  if (!tok) {
41
- throw new Error('S1_CONSOLE_API_TOKEN not configured. HEC uses the same Management Console API token as the Bearer.');
49
+ throw new Error(
50
+ 'S1_HEC_TOKEN not configured. Log ingest needs an SDL Log Write Key, not the ' +
51
+ 'Management Console API token: the event collector refuses a user token. ' +
52
+ 'Mint one at Console > Singularity Data Lake > API Keys > Log Write Key ' +
53
+ '(no API creates one) and set S1_HEC_TOKEN. The key is scoped to one account or ' +
54
+ 'site and writes only there.'
55
+ );
42
56
  }
43
57
  return tok;
44
58
  }
@@ -54,7 +68,10 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
54
68
  * @param {object} [opts.fields] Extra {key: value} pairs -> query params, each becomes a UI field.
55
69
  * Avoid HEC-reserved keys (event, time, host, source, sourcetype, index, fields):
56
70
  * HEC interprets those, they are not stored as custom fields. Use `parser` (not a field) to set sourcetype. (S-26.1 HEC docs, p.4708.)
57
- * @param {string} opts.scope REQUIRED. accountId or "accountId:siteId" -> S1-Scope header. HEC returns 400 "Missing S1-Scope header" without it.
71
+ * @param {string} [opts.scope] IGNORED, accepted only so existing callers do not break.
72
+ * The S1-Scope header is not sent: the Log Write Key already
73
+ * determines the destination and the collector does not honour
74
+ * an override. Passing one has no effect.
58
75
  * @param {('raw'|'event')} [opts.endpoint='raw']
59
76
  * For 'event', logContent must be newline-separated HEC JSON envelopes:
60
77
  * {"time": <epoch seconds>, "event": <string|object>, "fields": {...}}.
@@ -67,15 +84,13 @@ function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
67
84
  * @returns {Promise<{status:number, endpoint:string, url:string, body:any}>}
68
85
  */
69
86
  export async function hecIngest(logContent, { parser, fields = {}, scope, endpoint = 'raw', compress = true, isParsed = false } = {}) {
87
+ void scope; // accepted and deliberately unused; see the param doc above.
70
88
  if (typeof logContent !== 'string' || logContent.length === 0) {
71
89
  throw new Error('hecIngest: logContent must be a non-empty string.');
72
90
  }
73
91
  if (endpoint !== 'raw' && endpoint !== 'event') {
74
92
  throw new Error("hecIngest: endpoint must be 'raw' or 'event'.");
75
93
  }
76
- if (!scope || typeof scope !== 'string') {
77
- throw new Error('hecIngest: scope is required. HEC rejects requests without an S1-Scope header (400 "Missing S1-Scope header"). Pass an accountId or "accountId:siteId".');
78
- }
79
94
 
80
95
  const qs = new URLSearchParams();
81
96
  if (parser) qs.set('sourcetype', parser);
@@ -101,7 +116,8 @@ export async function hecIngest(logContent, { parser, fields = {}, scope, endpoi
101
116
  'Content-Type': endpoint === 'event' ? 'application/json' : 'text/plain',
102
117
  };
103
118
  if (compress) headers['Content-Encoding'] = 'gzip';
104
- headers['S1-Scope'] = scope;
119
+ // No S1-Scope. The Log Write Key fixes the destination; sending a scope does not
120
+ // move the events and an empty one is a different request to none at all.
105
121
 
106
122
  let delay = 1000;
107
123
  let lastErr;
package/lib/s1.js CHANGED
@@ -558,6 +558,44 @@ export async function uamAddNote(alertId, noteText) {
558
558
  }, null);
559
559
  }
560
560
 
561
+ /**
562
+ * What actions does the API say this caller may trigger on this alert?
563
+ *
564
+ * The authoritative capability answer, and the thing to consult before
565
+ * explaining any refused action. Returns the raw list, each entry carrying
566
+ * `{id, title, type, isDisabled, disabledReason}`.
567
+ *
568
+ * `alertAvailableActions` needs a non-null `scope`, unlike alertTriggerActions,
569
+ * so account ids are resolved first. Availability is scope-sensitive (measured:
570
+ * the S1/incident/* actions report
571
+ * INCIDENT_ACTIONS_ONLY_AVAILABLE_FROM_SITE_VIEW under ACCOUNT scope and are
572
+ * enabled under SITE), so pass `scope` explicitly when you care about a
573
+ * site-scoped action.
574
+ */
575
+ export async function uamAvailableActions(alertId, scope) {
576
+ let resolved = scope;
577
+ if (!resolved) {
578
+ const accts = await apiGet('/web/api/v2.1/accounts', { limit: 100 });
579
+ const ids = (accts?.data || []).map((a) => a.id).filter(Boolean);
580
+ if (!ids.length) throw new Error('no accounts visible to this token');
581
+ resolved = { scopeIds: ids, scopeType: 'ACCOUNT' };
582
+ }
583
+ const query = `
584
+ query AvailableActions($scope: ScopeSelectorInput!, $filter: OrFilterSelectionInput) {
585
+ alertAvailableActions(scope: $scope, filter: $filter) {
586
+ data { id title type isDisabled disabledReason }
587
+ errors { errorMessage }
588
+ }
589
+ }
590
+ `;
591
+ const variables = {
592
+ scope: resolved,
593
+ filter: { or: [{ and: [{ fieldId: 'id', stringEqual: { value: alertId } }] }] },
594
+ };
595
+ const data = await uamGraphql(query, variables, undefined, { readOnly: true });
596
+ return data?.alertAvailableActions?.data || [];
597
+ }
598
+
561
599
  /**
562
600
  * Update the status of a UAM alert via alertTriggerActions.
563
601
  * Valid status values (confirmed via Status enum introspection): NEW | IN_PROGRESS | RESOLVED
@@ -605,7 +643,35 @@ export async function uamSetStatus(alertId, status) {
605
643
  }
606
644
  if (action.failure?.length) {
607
645
  const f = action.failure[0];
608
- throw new Error(`uamSetStatus failed for alert ${alertId}: ${f.errorMessage || f.errorType || 'unknown error'}`);
646
+ // errorMessage says WHAT failed, not WHY. `Missing UAM manage permissions`
647
+ // is also what a not-offered action returns: alerts ingested via the UAM
648
+ // Alert Interface (/v1/alerts) expose only addNote and eventSearch, while
649
+ // the same token sets status on a native alert successfully (measured).
650
+ // So ask alertAvailableActions before reporting a cause, and never let the
651
+ // caller conclude "the token lacks permission" from the string alone.
652
+ let hint = '';
653
+ try {
654
+ const avail = await uamAvailableActions(alertId);
655
+ const ids = avail.map((a) => a.id);
656
+ if (!ids.includes('S1/alert/statusUpdate')) {
657
+ hint = ' | alertAvailableActions: statusUpdate is NOT OFFERED for this '
658
+ + `alert type (available: ${ids.join(', ') || 'none'}). This is a `
659
+ + 'capability limit of the alert, not a token scope; a new token '
660
+ + 'will not help.';
661
+ } else {
662
+ const a = avail.find((x) => x.id === 'S1/alert/statusUpdate');
663
+ hint = a?.isDisabled
664
+ ? ` | alertAvailableActions: offered but DISABLED (${a.disabledReason || 'no reason given'}).`
665
+ : ' | alertAvailableActions: statusUpdate IS available here, so the '
666
+ + 'refusal is not availability. Escalate as a genuine permission '
667
+ + 'or state problem.';
668
+ }
669
+ } catch (e) {
670
+ hint = ` | could not query alertAvailableActions to diagnose: ${e.message}`;
671
+ }
672
+ throw new Error(
673
+ `uamSetStatus failed for alert ${alertId}: ${f.errorMessage || f.errorType || 'unknown error'}${hint}`
674
+ );
609
675
  }
610
676
  if (!(action.success?.length) && action.skip?.length) {
611
677
  throw new Error(
package/lib/sdl.js CHANGED
@@ -31,7 +31,7 @@ export function sdlToken() {
31
31
  *
32
32
  * SDL objects (dashboards, saved searches, config files) are filed against the
33
33
  * scope the request carries, and reads are filtered by it. Verified live on
34
- * usea1-purple 2026-08-17, same token: `configFiles` returned 113 files at
34
+ * <console> 2026-08-17, same token: `configFiles` returned 113 files at
35
35
  * account scope (20 of them dashboards) and 4 at a site scope (all 4
36
36
  * dashboards). A dashboard created at site scope is invisible to an
37
37
  * account-scoped listing, so a missing header is not a neutral default, it
@@ -415,7 +415,7 @@ export async function deleteConfigFile({ name, udoId, expectedVersion, scope })
415
415
  //
416
416
  // A SECOND, HIGHER-LEVEL SURFACE on the same `POST /sdl/v2/graphql` endpoint.
417
417
  // This is what the console itself drives; captured from live console traffic on
418
- // usea1-purple 2026-08-17 (280 requests, 23 operations).
418
+ // <console> 2026-08-17 (280 requests, 23 operations).
419
419
  //
420
420
  // Relationship to the config-file layer above:
421
421
  //
@@ -94,7 +94,7 @@ const PROMPTS = [
94
94
 
95
95
  export const SERVER_INFO = {
96
96
  name: 's1-secops-mcp-server',
97
- version: '1.3.6',
97
+ version: '1.3.7',
98
98
  };
99
99
 
100
100
  export const PROTOCOL_VERSION = '2024-11-05';
package/lib/uam-ingest.js CHANGED
@@ -9,15 +9,19 @@
9
9
  * Scope : S1-Scope: <accountId>[:<siteId>[:<groupId>]] (mandatory)
10
10
  *
11
11
  * Endpoints:
12
- * POST /v1/indicators : OCSF behavioral indicators (batch: N per call)
13
- * POST /v1/alerts , OCSF SecurityAlert (ONE per call, see below)
12
+ * POST /v1/alerts , OCSF SecurityAlert (ONE per call, see below)
14
13
  *
15
- * Critical constraints (empirically confirmed on your-tenant 2026-04-22):
14
+ * INDICATORS ARE NOT SEPARATELY INGESTIBLE. There is no usable POST
15
+ * /v1/indicators any more: the endpoint refuses the console user token AND the
16
+ * SDL Log Write Key, so no credential can drive it. Every indicator now rides
17
+ * inside the alert, in finding_info.related_events[], and the Indicators tab in
18
+ * the console is fed from there. This removed the sleep, the ordering contract
19
+ * and a whole class of silent drop, so the single POST is simpler as well as
20
+ * being the only thing that works.
21
+ *
22
+ * Critical constraints (empirically confirmed on a live tenant):
16
23
  * - ONE alert per POST /v1/alerts. Multi-alert bodies return HTTP 202 but the
17
24
  * stitcher silently drops all but one. Loop callers for multiple alerts.
18
- * - Sleep ~3s between POST /v1/indicators and POST /v1/alerts. If the alert
19
- * lands before the indicator's metadata.uid is registered the stitcher silently
20
- * drops the alert (still HTTP 202). ingestAlert() enforces the sleep.
21
25
  * - file.hashes MUST be OCSF Fingerprint array [{algorithm_id, algorithm, value}],
22
26
  * NOT a plain dict. Dict form causes silent drop even on HTTP 202.
23
27
  * - finding_info.related_events[] entries MUST carry class_uid, type_uid,
@@ -197,13 +201,16 @@ export function buildFileIndicator({
197
201
  *
198
202
  * Returns a complete alert object ready to POST to /v1/alerts (one at a time).
199
203
  *
200
- * @param {boolean} [inline=false]
201
- * false (default): related_events[] contains only the reference fields (uid, class_uid,
202
- * type_uid, etc.) and observables. The stitcher resolves the full indicator from a
203
- * prior /v1/indicators POST via metadata.uid. Use with ingestAlert() (two-call flow).
204
- * true: related_events[] embeds the full indicator context (file, device, actor) inline.
205
- * No separate /v1/indicators POST is required: everything ships in one /v1/alerts call.
206
- * Use with ingestAlertInline() (single-call flow).
204
+ * @param {boolean} [inline=true]
205
+ * The only supported value is true, and it is the default. related_events[] embeds the
206
+ * full indicator context (file, device, actor) inline and everything ships in one
207
+ * /v1/alerts call.
208
+ *
209
+ * Passing false used to emit reference-only entries for the stitcher to resolve against
210
+ * a prior /v1/indicators POST. That POST is no longer possible, so a reference-only
211
+ * alert now resolves to nothing: it is accepted with HTTP 202 and shows an empty
212
+ * Indicators tab. The parameter is still accepted so old callers do not crash, but it
213
+ * is forced to true and a warning is emitted.
207
214
  */
208
215
  export function buildSecurityAlert({
209
216
  alertUid,
@@ -212,7 +219,7 @@ export function buildSecurityAlert({
212
219
  description = 'Synthetic test alert created by s1-secops-mcp uam_ingest_alert.',
213
220
  detectionProduct = 'smoke-product',
214
221
  detectionVendor = 'smoke-vendor',
215
- inline = false,
222
+ inline = true,
216
223
  nowMs,
217
224
  } = {}) {
218
225
  const ts = nowMs || Date.now();
@@ -286,72 +293,19 @@ export function buildSecurityAlert({
286
293
 
287
294
  // ─── High-level end-to-end helpers ────────────────────────────────────────────
288
295
 
289
- /**
290
- * Create a synthetic test alert in UAM end-to-end.
291
- *
292
- * Builds an OCSF FileSystem Activity indicator and a SecurityAlert,
293
- * POSTs them to the HEC ingest host with the required 3s sleep in between,
294
- * and returns the UIDs and HTTP responses.
296
+ /* ingestAlert(), the two-step indicator-then-alert flow, was REMOVED.
295
297
  *
296
- * The alert typically surfaces in UAM within 30-60s. Search by title or
297
- * poll uam_list_alerts.
298
+ * It posted the indicator to /v1/indicators, slept ~3s for the stitcher, then
299
+ * posted an alert referencing it by uid. That path no longer works: the
300
+ * indicators endpoint refuses both the console user token and the SDL Log Write
301
+ * Key, so there is no credential that can drive it. Indicators are now carried
302
+ * inline in the alert body instead, which is what ingestAlertInline() below
303
+ * does, and which never needed the sleep or the sequencing in the first place.
298
304
  *
299
- * @param {object} opts
300
- * @param {string} opts.scope accountId or "accountId:siteId" (mandatory)
301
- * @param {string} [opts.title] Alert name shown in UAM (default: "MCP Test Alert")
302
- * @param {string} [opts.description] Alert description
303
- * @param {string} [opts.hostname] Hostname for the indicator device
304
- * @param {string} [opts.filename] Filename for the FileSystem indicator
305
- * @param {string} [opts.sha256] SHA-256 hash (64 hex chars); random if omitted
306
- * @param {number} [opts.sleepMs=3000] Sleep between indicator POST and alert POST
305
+ * Deliberately deleted rather than left throwing: a function that can only fail
306
+ * invites callers to keep a code path alive for it.
307
307
  */
308
- export async function ingestAlert({
309
- scope,
310
- title = 'MCP Test Alert',
311
- description = 'Synthetic test alert created by s1-secops-mcp uam_ingest_alert.',
312
- hostname = 'mcp-test-host',
313
- filename = 'test-payload.exe',
314
- sha256,
315
- sleepMs = 3000,
316
- } = {}) {
317
- if (!scope) throw new Error('scope is required (accountId or "accountId:siteId").');
318
-
319
- const nowMs = Date.now();
320
- const indicatorUid = randomUUID();
321
- const alertUid = randomUUID();
322
-
323
- const indicator = buildFileIndicator({
324
- indicatorUid,
325
- filename,
326
- sha256,
327
- hostname,
328
- nowMs,
329
- });
330
-
331
- const indicatorResp = await hecPost('/v1/indicators', [indicator], scope);
332
308
 
333
- // Wait for the stitcher to register the indicator uid before posting the alert.
334
- // Reducing below ~2s has been observed to cause silent drops on loaded tenants.
335
- await sleep(sleepMs);
336
-
337
- const alert = buildSecurityAlert({
338
- alertUid,
339
- indicator,
340
- title,
341
- description,
342
- nowMs,
343
- });
344
-
345
- const alertResp = await hecPost('/v1/alerts', alert, scope);
346
-
347
- return {
348
- indicator_uid: indicatorUid,
349
- alert_uid: alertUid,
350
- indicator_response: indicatorResp,
351
- alert_response: alertResp,
352
- next_step: `Allow 30-60s then call uam_list_alerts to find the alert by title "${title}". Use uam_get_alert with the returned ID for full details.`,
353
- };
354
- }
355
309
 
356
310
  /**
357
311
  * Create a synthetic test alert in UAM in a single /v1/alerts POST.
@@ -411,15 +365,10 @@ export async function ingestAlertInline({
411
365
 
412
366
  // ─── Low-level raw-payload helpers ────────────────────────────────────────────
413
367
 
414
- /**
415
- * POST raw OCSF indicators to /v1/indicators.
416
- * Caller is responsible for correct OCSF shape.
368
+ /* postIndicators() was REMOVED along with the /v1/indicators path it wrapped.
369
+ * See the note above ingestAlertInline(). Indicators ride inside the alert.
417
370
  */
418
- export async function postIndicators({ scope, indicators }) {
419
- if (!scope) throw new Error('scope is required.');
420
- const items = Array.isArray(indicators) ? indicators : [indicators];
421
- return hecPost('/v1/indicators', items, scope);
422
- }
371
+
423
372
 
424
373
  /**
425
374
  * POST a single raw OCSF SecurityAlert to /v1/alerts.
@@ -437,7 +386,14 @@ export async function postAlert({ scope, alert }) {
437
386
  return hecPost('/v1/alerts', alert, scope);
438
387
  }
439
388
 
440
- /** True if HEC ingest credentials are configured. */
389
+ /** True if UAM ingest credentials are configured.
390
+ *
391
+ * UAM alert ingest posts OCSF to /v1/alerts on the ingest host and authenticates
392
+ * with the CONSOLE token, not the Log Write Key: alert creation and IOCs are
393
+ * user-token operations. Only raw log ingest over the event collector uses
394
+ * S1_HEC_TOKEN, and that is checked separately in hec.js. Conflating the two is
395
+ * what made this function demand the wrong credential.
396
+ */
441
397
  export function hasHecCreds() {
442
398
  const c = getCreds();
443
399
  return !!(c.S1_HEC_INGEST_URL && c.S1_CONSOLE_API_TOKEN);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmoses-s1/s1-secops-mcp",
3
- "version": "1.3.6",
3
+ "version": "1.3.7",
4
4
  "description": "MCP server orchestrating SentinelOne skills, APIs, and SOC analyst context. Stdio or Streamable HTTP transport with per-user bearer auth for team deployments.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -60,7 +60,6 @@ const TOOL_SKILL = {
60
60
  ha_export_workflow: 'hyperautomation',
61
61
  // UAM Ingest
62
62
  uam_ingest_alert: 'mgmt-console-api (UAM Alert Interface)',
63
- uam_post_indicators: 'mgmt-console-api (UAM Alert Interface)',
64
63
  uam_post_alert: 'mgmt-console-api (UAM Alert Interface)',
65
64
  };
66
65
 
@@ -12,6 +12,7 @@
12
12
  * uam_get_alert Get full alert details (notes, history)
13
13
  * uam_add_note Add analyst note to an alert
14
14
  * uam_set_status Update alert status (NEW, IN_PROGRESS, RESOLVED)
15
+ * uam_available_actions What actions this alert allows (isDisabled + reason)
15
16
  *
16
17
  * REMOVED (2026-05-03: confirmed non-functional for API tokens):
17
18
  * purple_ai_query : requires browser-session teamToken from /sdl/v2/graphql that
@@ -19,7 +20,7 @@
19
20
  * purple_ai_investigate : same root cause (SERVICE_ERROR). Use Purple MCP instead.
20
21
  */
21
22
 
22
- import { apiGet, apiPost, apiPut, apiDelete, apiPatch, purpleAlertSummary, uamListAlerts, uamGetAlert, uamAddNote, uamSetStatus } from '../lib/s1.js';
23
+ import { apiGet, apiPost, apiPut, apiDelete, apiPatch, purpleAlertSummary, uamListAlerts, uamGetAlert, uamAddNote, uamSetStatus, uamAvailableActions } from '../lib/s1.js';
23
24
 
24
25
  /**
25
26
  * Defensive normalization for GET /cloud-detection/rules calls.
@@ -341,4 +342,49 @@ export const tools = [
341
342
  return JSON.stringify(result, null, 2);
342
343
  },
343
344
  },
345
+
346
+ // ─── uam_available_actions ────────────────────────────────────────────────
347
+ {
348
+ name: 'uam_available_actions',
349
+ description: `Ask the API which actions can be triggered on a UAM alert, with isDisabled and disabledReason per action. This is the authoritative capability answer and the ONLY correct way to explain a refused write. A refused action returns errorMessage "Missing UAM manage permissions" whether the token lacks a scope OR the action is simply not offered for that alert type, and those need opposite responses. Measured on one tenant with one token: an alert ingested via the UAM Alert Interface (/v1/alerts) offers only S1/alert/addNote and S1/alert/eventSearch, so uam_set_status can never work on it and no token change helps; a native STAR / third-party / correlation alert offers S1/alert/statusUpdate and S1/alert/analystVerdictUpdate and the same token applies them successfully. Call this before concluding anything about permissions, and before a bulk write you cannot undo. Availability is also scope-sensitive: the S1/incident/* actions report INCIDENT_ACTIONS_ONLY_AVAILABLE_FROM_SITE_VIEW under ACCOUNT scope and are enabled under SITE, so pass scopeIds/scopeType when you care about a site-scoped action. Read-only.`,
350
+ inputSchema: {
351
+ type: 'object',
352
+ properties: {
353
+ alertId: {
354
+ type: 'string',
355
+ description: 'The UAM alert ID to check.',
356
+ },
357
+ scopeIds: {
358
+ type: 'array',
359
+ items: { type: 'string' },
360
+ description: 'Optional. Account or site ids for the scope. Defaults to every account visible to the token, resolved via GET /accounts.',
361
+ },
362
+ scopeType: {
363
+ type: 'string',
364
+ description: 'Optional. Scope type for scopeIds. Availability differs between ACCOUNT and SITE for some actions.',
365
+ enum: ['ACCOUNT', 'SITE', 'GROUP', 'GLOBAL'],
366
+ },
367
+ },
368
+ required: ['alertId'],
369
+ },
370
+ async handler({ alertId, scopeIds, scopeType }) {
371
+ const scope = scopeIds?.length
372
+ ? { scopeIds, scopeType: scopeType || 'ACCOUNT' }
373
+ : undefined;
374
+ const actions = await uamAvailableActions(alertId, scope);
375
+ return JSON.stringify(
376
+ {
377
+ alertId,
378
+ scope: scope || 'all accounts visible to the token (ACCOUNT)',
379
+ enabled: actions.filter((a) => !a.isDisabled).map((a) => a.id),
380
+ disabled: actions
381
+ .filter((a) => a.isDisabled)
382
+ .map((a) => ({ id: a.id, reason: a.disabledReason })),
383
+ actions,
384
+ },
385
+ null,
386
+ 2
387
+ );
388
+ },
389
+ },
344
390
  ];
package/tools/sdl-api.js CHANGED
@@ -307,19 +307,22 @@ export const tools = [
307
307
  // ─── hec_ingest ─────────────────────────────────────────────────────────────
308
308
  {
309
309
  name: 'hec_ingest',
310
- description: `Ingest raw logs/events into the SentinelOne AI SIEM Singularity Data Lake via the HEC (HTTP Event Collector) endpoint. Applies a named parser via ?sourcetype and lands the data in the Data Lake for Event Search, PowerQuery, and detection rules. Replaces the removed sdl_upload_logs. NOT UAM ingest (the uam_* tools post OCSF indicators/alerts to /v1/* on the same host but a separate API). Per S-26.1 HEC docs: POST {S1_HEC_INGEST_URL}/services/collector/raw, Authorization: Bearer <S1_CONSOLE_API_TOKEN>, query params become fields, gzip recommended, 10 MB uncompressed per request.`,
310
+ description: `Ingest raw logs/events into the SentinelOne AI SIEM Singularity Data Lake via the HEC (HTTP Event Collector) endpoint. Applies a named parser via ?sourcetype and lands the data in the Data Lake for Event Search, PowerQuery, and detection rules. Replaces the removed sdl_upload_logs. NOT UAM ingest (the uam_* tools post OCSF indicators/alerts to /v1/* on the same host but a separate API). POST {S1_HEC_INGEST_URL}/services/collector/raw with Authorization: Bearer <S1_HEC_TOKEN>, an SDL Log Write Key. NOT the Management Console API token: the collector refuses it. Measured on a live tenant, identical request: write key returns 200 {"text":"Success","code":0}, console token returns 400 {"text":"Missing S1-Scope header","code":5}. Mint the key at Console > Singularity Data Lake > API Keys > Log Write Key; no API creates one. The key is issued for one account or site and writes only there, so it fixes the destination and NO S1-Scope header is sent. Query params become fields, gzip recommended, 10 MB uncompressed per request.`,
311
311
  inputSchema: {
312
312
  type: 'object',
313
313
  properties: {
314
314
  logContent: { type: 'string', description: 'Raw log text. For the /raw endpoint, newline-separated lines become separate events.' },
315
315
  parser: { type: 'string', description: 'Parser name, sent as the ?sourcetype= query param. Omit to skip parsing (structured JSON on /event auto-parses).' },
316
316
  fields: { type: 'object', description: 'Extra key-value pairs sent as query params; each key becomes a field in the UI, e.g. {"server":"dev","region":"ap1"}. Avoid HEC-reserved names (event, time, host, source, sourcetype, index, fields) as keys; use the parser arg to set sourcetype.' },
317
- scope: { type: 'string', description: 'REQUIRED. accountId or "accountId:siteId" sent as the S1-Scope header; HEC rejects requests without it (400 "Missing S1-Scope header").' },
317
+ scope: { type: 'string', description: 'IGNORED, accepted only so older callers do not break. The Log Write Key already fixes the destination and no S1-Scope header is sent, so passing this has no effect. To write to a different account or site, use a key minted for that scope.' },
318
318
  endpoint: { type: 'string', enum: ['raw','event'], description: "HEC endpoint: 'raw' (default, raw text) or 'event' (structured JSON)." },
319
319
  compress: { type: 'boolean', description: 'gzip the body (Content-Encoding: gzip). Default true.' },
320
320
  isParsed: { type: 'boolean', description: 'For /event with structured JSON: set ?isParsed=true so SDL indexes the JSON fields directly, with no SDL parser. Confirmed working.' },
321
321
  },
322
- required: ['logContent', 'scope'],
322
+ // `scope` is NOT required any more. Leaving it in `required` made the schema
323
+ // demand an argument the implementation discards, so a correct call looked
324
+ // invalid and an invalid one looked correct.
325
+ required: ['logContent'],
323
326
  },
324
327
  async handler({ logContent, parser, fields, scope, endpoint, compress, isParsed }) {
325
328
  const result = await hecIngest(logContent, { parser, fields, scope, endpoint, compress, isParsed });
@@ -3,28 +3,35 @@
3
3
  * via the SentinelOne HEC ingest host (ingest.us1.sentinelone.net).
4
4
  *
5
5
  * Tools:
6
- * uam_ingest_alert End-to-end: build + POST one FileSystem indicator + one SecurityAlert
7
- * uam_post_indicators Low-level: POST raw OCSF indicators to /v1/indicators
6
+ * uam_ingest_alert End-to-end: build + POST one SecurityAlert carrying its indicator inline
8
7
  * uam_post_alert Low-level: POST a single raw OCSF SecurityAlert to /v1/alerts
9
8
  *
10
- * These tools require S1_HEC_INGEST_URL in credentials.json in addition to
11
- * S1_CONSOLE_API_TOKEN (same token, Bearer prefix instead of ApiToken).
9
+ * uam_post_indicators WAS REMOVED. Indicators can no longer be ingested on their
10
+ * own: /v1/indicators refuses the console user token and the SDL Log Write Key
11
+ * alike, so no credential can drive it. They are carried inside the alert, in
12
+ * finding_info.related_events[], which is also what the console Indicators tab
13
+ * reads. A tool that can only ever return 403 is worse than no tool, because it
14
+ * reads as a supported path.
15
+ *
16
+ * These tools require S1_HEC_INGEST_URL plus S1_CONSOLE_API_TOKEN. Alert creation
17
+ * and IOCs remain user-token operations; only raw LOG ingest over the event
18
+ * collector uses S1_HEC_TOKEN.
12
19
  */
13
20
 
14
- import { ingestAlert, ingestAlertInline, postIndicators, postAlert } from '../lib/uam-ingest.js';
21
+ import { ingestAlertInline, postAlert } from '../lib/uam-ingest.js';
15
22
 
16
23
  export const tools = [
17
24
 
18
25
  // ─── uam_ingest_alert ─────────────────────────────────────────────────────
19
26
  {
20
27
  name: 'uam_ingest_alert',
21
- description: `Create a synthetic test alert in Unified Alert Management (UAM) via the SentinelOne HEC ingest API. Supports two modes controlled by the "inline" parameter:
28
+ description: `Create a synthetic test alert in Unified Alert Management (UAM) via the SentinelOne ingest API.
22
29
 
23
- Two-call mode (inline=false, default): POST indicator to /v1/indicators, sleep 3s, POST SecurityAlert to /v1/alerts referencing the indicator uid. The stitcher resolves the full indicator into alert.rawIndicators. Best for testing deep indicator stitching and the Indicators tab in UAM.
30
+ ONE round-trip: a single SecurityAlert POSTed to /v1/alerts with its indicator embedded in finding_info.related_events[]. No sleep, no stitcher race, no ordering contract. That inline copy is what populates alert.indicators, the field the console Indicators tab renders.
24
31
 
25
- Inline mode (inline=true): POST a single SecurityAlert to /v1/alerts with the indicator's file/device/actor fields embedded inside finding_info.related_events[]. No separate indicator POST, no sleep, one round-trip. Best for rapid alert creation or when a single call is preferred.
32
+ There is no longer a two-call alternative. Posting indicators separately to /v1/indicators is refused for every credential type: the console user token and the SDL Log Write Key both fail, so nothing can drive that endpoint. The old flow also wrote alert.rawIndicators, a separate store the UI never read. The inline parameter is still accepted so existing callers do not break, but it is forced to true.
26
33
 
27
- Both modes return indicator_uid and alert_uid. The alert surfaces in UAM within 30-60s. Requires S1_HEC_INGEST_URL in credentials.json.`,
34
+ Returns indicator_uid and alert_uid. The alert surfaces in UAM within 30-60s. Verify with alert(id){indicators{...}}; alertWithRawIndicators stays empty by design. Requires S1_HEC_INGEST_URL and S1_CONSOLE_API_TOKEN.`,
28
35
  inputSchema: {
29
36
  type: 'object',
30
37
  properties: {
@@ -55,48 +62,26 @@ Both modes return indicator_uid and alert_uid. The alert surfaces in UAM within
55
62
  type: 'string',
56
63
  description: 'SHA-256 hash (64 lowercase hex chars). If omitted, a zeroed placeholder hash is used.',
57
64
  },
58
- sleep_ms: {
59
- type: 'number',
60
- description: 'Two-call mode only. Milliseconds to sleep between the indicator POST and the alert POST. Default 3000. Do not go below 2000 on loaded tenants.',
61
- default: 3000,
62
- },
63
65
  inline: {
64
66
  type: 'boolean',
65
- description: 'When true, embed indicator data (file, device, actor, observables) directly inside the alert\'s finding_info.related_events[] and POST only to /v1/alerts; no separate /v1/indicators call, no sleep. When false (default), use the two-call flow: POST indicator first, sleep, then POST alert.',
66
- default: false,
67
+ description: 'Accepted for backward compatibility and ignored: the value is always true. Indicators ride inside the alert because there is no working way to post them separately. Passing false returns a note saying so rather than silently doing something different.',
68
+ default: true,
67
69
  },
68
70
  },
69
71
  required: ['scope'],
70
72
  },
71
- async handler({ scope, title, description, hostname, filename, sha256, sleep_ms = 3000, inline = false }) {
72
- const result = inline
73
- ? await ingestAlertInline({ scope, title, description, hostname, filename, sha256 })
74
- : await ingestAlert({ scope, title, description, hostname, filename, sha256, sleepMs: sleep_ms });
75
- return JSON.stringify(result, null, 2);
76
- },
77
- },
78
-
79
- // ─── uam_post_indicators ──────────────────────────────────────────────────
80
- {
81
- name: 'uam_post_indicators',
82
- description: `POST one or more raw OCSF behavioral indicators to /v1/indicators on the SentinelOne HEC ingest host. Batching is supported; pass multiple indicators in the array and they are sent in a single gzip-compressed request. Each indicator must carry metadata.profiles=["s1/security_indicator"] and a unique metadata.uid (used as the join key when an alert references it). After posting, wait at least 3s before posting a SecurityAlert that references these indicator uids (use uam_post_alert or uam_ingest_alert which enforce the sleep). Requires S1_HEC_INGEST_URL in credentials.json.`,
83
- inputSchema: {
84
- type: 'object',
85
- properties: {
86
- scope: {
87
- type: 'string',
88
- description: 'accountId or "accountId:siteId". Mandatory.',
89
- },
90
- indicators: {
91
- type: 'array',
92
- description: 'Array of OCSF indicator objects. Each must have metadata.uid, metadata.profiles=["s1/security_indicator"], class_uid, and observables[]. file.hashes must be a Fingerprint array [{algorithm_id, algorithm, value}], not a plain dict.',
93
- items: { type: 'object', additionalProperties: true },
94
- },
95
- },
96
- required: ['scope', 'indicators'],
97
- },
98
- async handler({ scope, indicators }) {
99
- const result = await postIndicators({ scope, indicators });
73
+ async handler({ scope, title, description, hostname, filename, sha256, inline = true }) {
74
+ const result = await ingestAlertInline({ scope, title, description, hostname, filename, sha256 });
75
+ // Say so rather than quietly substituting a different behaviour. A caller
76
+ // that asked for the two-call flow is working from a stale assumption and
77
+ // needs to know the request was not honoured as written.
78
+ if (inline === false) {
79
+ result.note =
80
+ 'inline:false was ignored. Indicators cannot be posted separately any more: ' +
81
+ '/v1/indicators refuses both the console token and the SDL Log Write Key. ' +
82
+ 'The indicator was embedded in the alert instead, which is what the ' +
83
+ 'console Indicators tab reads.';
84
+ }
100
85
  return JSON.stringify(result, null, 2);
101
86
  },
102
87
  },
@@ -104,7 +89,7 @@ Both modes return indicator_uid and alert_uid. The alert surfaces in UAM within
104
89
  // ─── uam_post_alert ───────────────────────────────────────────────────────
105
90
  {
106
91
  name: 'uam_post_alert',
107
- description: `POST a single raw OCSF SecurityAlert to /v1/alerts on the SentinelOne HEC ingest host. IMPORTANT: one alert per call. The HEC stitcher silently drops all but one alert in a multi-alert POST body (HTTP 202 still returned), so this tool rejects arrays. To send multiple alerts, loop this call. Always post indicator(s) first via uam_post_indicators and sleep at least 3s before calling this; posting an alert before its indicator uids are registered causes a silent drop. Requires S1_HEC_INGEST_URL in credentials.json.`,
92
+ description: `POST a single raw OCSF SecurityAlert to /v1/alerts on the SentinelOne HEC ingest host. IMPORTANT: one alert per call. The HEC stitcher silently drops all but one alert in a multi-alert POST body (HTTP 202 still returned), so this tool rejects arrays. To send multiple alerts, loop this call. Carry the indicator INLINE in finding_info.related_events[] (uid, title, desc, message, time, severity_id, class_uid, type_uid, category_uid, activity_id, and observables[] with type + typeName). That alone populates alert.indicators, which is what the UAM Indicators tab renders; no /v1/indicators call is needed and none should be made, because that endpoint returns 403 "User token not allowed for this endpoint" for a service-user token while /v1/alerts accepts the same credential. Requires S1_HEC_INGEST_URL in credentials.json.`,
108
93
  inputSchema: {
109
94
  type: 'object',
110
95
  properties: {