@pmoses-s1/s1-secops-mcp 1.3.2 → 1.3.3

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,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.3 - 2026-08-07
4
+
5
+ Fixes a user-facing regression in 1.3.2 found by running the live MCP tools
6
+ against a tenant for the first time. **Upgrade from 1.3.2 is recommended.**
7
+
8
+ ### Fixed
9
+
10
+ - **Every successful `sdl_delete_file` reported an error in 1.3.2.** The delete
11
+ verification added in 1.3.2 re-reads the file to confirm removal, but the server
12
+ reports absence as a GraphQL error rather than a null result, so the confirming
13
+ read threw on exactly the success path. The delete itself always worked; only the
14
+ reported outcome was wrong. Verified live: three deletes across `/dashboards/`,
15
+ `/datatables/` and `/logParsers/` all removed their file and all three surfaced
16
+ as errors.
17
+ - **`sdl_get_file` on a missing path returned a raw GraphQL error** instead of the
18
+ actionable "this may be udoId-addressed, list it and retry" hint. The hint branch
19
+ was unreachable because the library threw before returning.
20
+ - **Absence is now detected reliably on both address forms.** The error text differs:
21
+ a missing name gives "Config file with name X not found.", a missing `udoId` gives
22
+ the generic "Something went wrong. Please try again...", which is also what a
23
+ version conflict returns. `configFile` normalises the explicit form and
24
+ disambiguates the generic one against the file listing, so a deleted dashboard
25
+ reads as absent while a genuine server error still propagates.
26
+ - **An out-of-range numeric `udoId` is no longer swallowed by the absence path.**
27
+ Validation now runs before the lookup, so a caller bug surfaces as a caller bug.
28
+ - **A transport error is never read as "file absent".** Absence detection now requires a
29
+ GraphQL-layer error, so a 404 page or WAF block whose body contains the words "not found"
30
+ no longer satisfies it. Without this a delete could confirm itself against a file it
31
+ never checked.
32
+ - **A failing listing during absence disambiguation keeps the original error** instead of
33
+ replacing it with the listing failure.
34
+ - **The duplicate guard is no longer bypassed by case.** Its namespace test was
35
+ case-sensitive while its name comparison was not, so `/Dashboards/AI Usage` skipped the
36
+ guard entirely. Both now share one normaliser.
37
+ - **`v1Query` keeps its backoff.** Restricting status retry to idempotent methods removed it
38
+ from this read-only POST, which schema discovery iterates once per data source.
39
+
40
+ ### Python client (`sdl-api/scripts/sdl_client.py`)
41
+
42
+ Brought to parity with the JS client:
43
+
44
+ - Status retry is restricted to idempotent methods. The Python client was retrying POST
45
+ mutations, which is the mechanism that duplicates a dashboard on a re-sent write.
46
+ - `Retry-After` is capped at 30s; an unbounded value parked the process.
47
+ - The duplicate guard fails closed on an empty listing.
48
+ - Absence detection, name normalisation and delete verification match the JS behaviour.
49
+
50
+ ### Tests
51
+
52
+ - `tests/sdl-graphql.test.mjs`: 86 cases, adding the transport-error-is-not-absence case,
53
+ listing-failure error preservation, the case-variant guard bypass, and `v1Query` retry.
54
+ - `sdl-api/tests/test_client.py`: new. 19 cases over a stubbed session, so the Python client
55
+ is no longer invisible to CI. Runs in ~0.01s with no network.
56
+ - Live regression through the real MCP stdio protocol: handshake, 26 tools, and full
57
+ create/read/update/stale-reject/delete/confirm-absent cycles for `/datatables/` and
58
+ `/dashboards/`, plus the duplicate guard, the notFound hint, and `/automaticLookups`.
59
+
3
60
  ## 1.3.2 - 2026-08-07
4
61
 
5
62
  Config-file operations move from the legacy REST endpoints to GraphQL. Tool count unchanged at
@@ -38,7 +95,8 @@ Config-file operations move from the legacy REST endpoints to GraphQL. Tool coun
38
95
 
39
96
  - `udoId` is assigned by namespace, verified live: only `/dashboards/` files get one. `/lookups/`,
40
97
  `/datatables/`, `/logParsers/` and `/automaticLookups` are name-addressed with `udoId` null.
41
- - `expectedVersion` is honoured on `udoId`-addressed writes and ignored on name-addressed ones.
98
+ - `expectedVersion` is enforced on both address forms. A stale value is rejected with
99
+ "There are conflicting changes in the file." and the stored content is left untouched.
42
100
  - A `deleteConfigFile` returning `null` with no `errors` array is success, not failure.
43
101
  - The scoped SDL keys (`SDL_CONFIG_READ_KEY` and friends) are retired; the console API token
44
102
  covers every SDL operation.
@@ -48,6 +106,7 @@ Config-file operations move from the legacy REST endpoints to GraphQL. Tool coun
48
106
  Hardening release from the 2026-07-31 code review. Tool count unchanged at 26.
49
107
 
50
108
  ### Fixed
109
+
51
110
  - **Missing `Retry-After` header no longer sleeps 0ms before retrying.** `Number(null)` is 0, so `lib/s1.js`, `lib/hec.js`, and `lib/uam-ingest.js` treated an absent header as "wait 0ms" and hammered the backend. All three now use the validated pattern from `lib/sdl.js`: honor the header only when present and parseable as a finite number of seconds (capped at 30s), otherwise fall back to the exponential delay. Numeric headers behave exactly as before.
52
111
  - **`uam_set_status` no longer reports silent success.** The `alertTriggerActions` mutation selected only `__typename`, so a skipped or failed action still returned as if it worked (observed live: status unchanged after a "successful" call). The selection now mirrors the full `actions { success failure skip }` shape and the client throws when the backend reports a failure entry, skips the action without a success, or returns an empty actions list (nothing applied, e.g. the filter matched no alert). `uam_add_note` was audited for the same pattern; it already verifies via the returned note list.
53
112
  - **LRQ polling tolerates transient poll errors.** A single 429/5xx poll response used to throw and cancel the running query. Transient statuses now keep polling (interval doubles up to 5s) until the existing 5-minute deadline; other 4xx responses remain fatal.
@@ -59,6 +118,7 @@ Hardening release from the 2026-07-31 code review. Tool count unchanged at 26.
59
118
  - **SDL `config_read` key chain reordered to least-privilege first** (config_read, config_write, console JWT), matching the Python `SDLClient` the header claims to mirror.
60
119
 
61
120
  ### Changed
121
+
62
122
  - **Removed dead exports `purpleAiQuery` and `purpleAiInvestigate`** from `lib/s1.js`; their MCP tools were removed 2026-05-03 (browser-session teamToken requirement) and nothing referenced the library functions. Corrected stale doc text that pointed at a nonexistent `uam_set_analyst_verdict` tool: the analyst verdict is set via a raw `alertTriggerActions` mutation with the `analystVerdictUpdate` action through `s1_api_post`.
63
123
  - **Deploy docs: credentials.json changes require `systemctl restart`.** SIGHUP reload only re-reads bearer tokens; the installer output and systemd unit comment said reload was enough. `deploy/install.sh` also sets `umask 077` so token/credential files are never world-readable at creation (the explicit `chmod 600` lines remain).
64
124
  - **Claude Desktop bridge hardening:** 120s fetch timeout (`AbortSignal.timeout`), notification responses are drained so keep-alive sockets are released, and the URL constant no longer shadows the global `URL` constructor.
@@ -66,6 +126,7 @@ Hardening release from the 2026-07-31 code review. Tool count unchanged at 26.
66
126
  - **`const status = response.error ? 200 : 200`** simplified; JSON-RPC errors still return HTTP 200 with an error envelope.
67
127
 
68
128
  ### Tests
129
+
69
130
  - New `tests/regressions-2026-07-31.test.mjs` (mocked fetch, no network): missing `Retry-After` uses the exponential delay, `uamSetStatus` throws on failure/skip results, inline `?isLegacy=` is honored. Both regression suites are now part of `npm test`.
70
131
  - Transport and smoke tests read the expected version from `package.json` instead of a hardcoded string.
71
132
 
@@ -74,12 +135,14 @@ Hardening release from the 2026-07-31 code review. Tool count unchanged at 26.
74
135
  Correctness release from the 2026-07-29 defect review. Fixes two bugs that produced plausible-but-wrong query results, hardens the SDL auth chain and HTTP retry paths, corrects the HEC `/event` content type, and adds a regression suite. Tool count unchanged at 26. (The Docker bundle image moved to 1.2.4 pinning `S1_MCP_VERSION=1.2.3`; the image tag stays ahead of the npm tag as it has since the split.)
75
136
 
76
137
  ### Fixed
138
+
77
139
  - **`powerquery_run` no longer collapses a caller-supplied time window.** The old code overwrote BOTH `startTime` and `endTime` with the last-`hours` default whenever either was missing, so a startTime-only call silently ran over the last 24 hours. Each bound now defaults independently. Live A/B: a startTime-only 7.4-day query returned 73,755 events on the fixed server vs 12,911 (the 24h control) on the old one.
78
140
  - **`powerquery_run` now reports `matchCount`.** It was read from the top level of the LRQ response and came back `null` on every call; it lives inside the `data` block on current engines.
79
141
  - **SDL auth chain falls through on 401/403.** `lib/sdl.js` treated an auth failure on the first configured key as fatal even when a later key (e.g. the console JWT) would work. It now advances through the chain and raises only when exhausted.
80
142
  - **HEC `/event` ingestion uses `application/json`,** so per-event `time` backdating is honored instead of the envelope being indexed as opaque text at receive time.
81
143
 
82
144
  ### Changed
145
+
83
146
  - **Write requests no longer auto-retry on 5xx.** Retry is restricted to idempotent methods (GET/HEAD); read-only POSTs (GraphQL queries, Purple AI launches) opt back in via `allowRetry`. HEC raw ingest no longer retries 5xx (no idempotency key); UAM ingest still retries because `metadata.uid` dedupes.
84
147
  - **`Retry-After` parsing hardened:** an HTTP-date value no longer collapses to `sleep(NaN)`; waits are validated and capped at 30s.
85
148
  - **`uam_add_note` returns the correct note** (matches by text, tiebreaks on newest `createdAt`) instead of assuming newest-last ordering.
@@ -88,15 +151,18 @@ Correctness release from the 2026-07-29 defect review. Fixes two bugs that produ
88
151
  - **`powerquery_schema_discover` escapes single quotes** in the data-source name before building the V1 filter.
89
152
 
90
153
  ### Tests
154
+
91
155
  - New `tests/regressions-2026-07-29.test.mjs` (mocked fetch, no network): independent time-bound defaults, `matchCount` extraction, SDL 401/403 fall-through, HEC per-endpoint content type, write-vs-read retry semantics.
92
156
 
93
157
  ## 1.2.2 - 2026-06-13
94
158
 
95
159
  ### Changed
160
+
96
161
  - **Renamed `ha_archive_workflow` to `ha_delete_workflow`.** The old tool hit `POST /hyper-automate/api/v1/workflows/archive`, which returns HTTP 500 on this tenant. The replacement uses the validated `DELETE /hyper-automate/api/v1/workflows/{id}` endpoint (a soft, recoverable delete equivalent to clicking Delete in the Hyperautomation UI). Scope the call with `accountIds` or `siteIds`; a 404 "Object not found" means the id is not under that scope or is already deleted. Updated `README.md`, the tools-table regenerator, and the smoke test in lockstep.
97
162
  - **`powerquery_run` description now documents the `datasource` and `savelookup` capabilities** (querying SentinelOne-managed inventory such as assets/alerts/vulnerabilities/misconfigurations, and persisting a result as a reusable lookup table), pointing at the new `powerquery/references/datasource-command.md`.
98
163
 
99
164
  ### Notes
165
+
100
166
  - Tool count unchanged at 26 (the Hyperautomation tool was renamed, not added or removed).
101
167
  - `SERVER_INFO.version` bumped in lockstep with `package.json` (the drift that forced the 1.2.0 -> 1.2.1 re-release).
102
168
 
@@ -105,22 +171,26 @@ Correctness release from the 2026-07-29 defect review. Fixes two bugs that produ
105
171
  Supersedes 1.2.0, which was deprecated on npm. The 1.2.0 build shipped with a stale internal `SERVER_INFO.version` of `1.1.0` despite a `1.2.0` package version, so the server announced the wrong version on `initialize`. 1.2.1 is identical in features and corrects the reported runtime version. The content below is unchanged from the 1.2.0 work.
106
172
 
107
173
  ### Added
174
+
108
175
  - **`hec_ingest` tool**: raw-log/event ingestion into the Singularity Data Lake via the HEC (HTTP Event Collector) endpoint (`/services/collector/raw` and `/services/collector/event`). Supports `parser` (-> `?sourcetype=`), custom `fields` (query params), **required** `scope` (S1-Scope header), gzip compression, and `isParsed` (-> `?isParsed=true`, indexes already-structured JSON with no SDL parser). Replaces the removed `sdl_upload_logs`. Validated live across the full HEC matrix (both endpoints, gzip on/off, parser field extraction, multi-line, batched, reserved-field handling, scope enforcement, isParsed). Grounded in the S-26.1 HEC docs (p.4723-4726).
109
176
 
110
177
  ### Removed
178
+
111
179
  - **`sdl_upload_logs` tool** plus the underlying SDL `uploadLogs`/`addEvents` library functions and `SDL_LOG_WRITE_KEY` plumbing. SDL raw-log ingestion moves to the HEC path (`hec_ingest`). The `sdl-api` skill is now query + configuration only; the `sdl-log-parser` validation loop uses HEC ingest.
112
180
 
113
181
  ### Changed
182
+
114
183
  - Tool count unchanged at 26 (removed `sdl_upload_logs`, added `hec_ingest`).
115
184
  - Skill docs corrected: scheduled detection rules bind the Target Asset via `entityMappings` ("Entity column mapping"); the full scheduled-rule option set (UI <-> API) is catalogued in `powerquery/references/detection-rules.md`.
116
185
 
117
-
118
186
  ## 1.1.0 - 2026-05-28 (rebuilt 2026-05-31)
119
187
 
120
188
  ### Fixed (rebuild)
189
+
121
190
  - **`s1_api_get` now auto-injects `isLegacy=false` for `/cloud-detection/rules` listings.** Without `isLegacy=false` the S1 API silently omits `queryType="scheduled"` PowerQuery rules from the response; no error, no warning, the response just lies by omission. The handler now guards against this when the caller forgets, and the tool description loudly flags the requirement. This eliminates the "I see zero scheduled detections" failure mode that was producing wrong verdicts when listing Custom Detection rules. Same `1.1.0` version per the rebuild request.
122
191
 
123
192
  ### Added
193
+
124
194
  - **Streamable HTTP transport.** New `--transport http` mode (default stays `stdio`). Single-endpoint POST `/mcp` per the MCP 2024-11-05 spec, plus `/healthz` for load balancer probes. Implementation is pure `node:http`, no new dependencies.
125
195
  - **Per-user bearer token auth.** New `MCP_BEARER_TOKENS_FILE` env var pointing at a `{ "<name>": "<token>" }` JSON file gives each team member a stable name in audit logs and supports rotation. SIGHUP reloads tokens without dropping connections. `MCP_BEARER_TOKENS` env var (comma-separated raw tokens) is a fallback for small or quick-test setups.
126
196
  - **Audit logging.** Every authenticated HTTP request emits `[audit] <ts> | <name> | <method> | <param-summary> | <status>` to stderr; systemd captures it via journald.
@@ -137,12 +207,14 @@ Supersedes 1.2.0, which was deprecated on npm. The 1.2.0 build shipped with a st
137
207
  - **README auto-regenerator** at `scripts/regen-readme-tools-table.mjs`. `npm run regen:readme` keeps the README table in sync with `ALL_TOOLS`. `npm run regen:readme -- --check` fails when stale (suitable for CI).
138
208
 
139
209
  ### Fixed
210
+
140
211
  - **README tool table.** Previous count was 19; actual is 26. Auto-generated now.
141
212
  - **Header comment in `index.js`.** Previously said 21; updated to 26.
142
213
  - **`purple_ai_query`** removed from the documentation. The tool itself was removed 2026-05-03 because the underlying API requires a browser-session `teamToken` that service-account API tokens never obtain. The README, `index.js`, and `docs/mcp-tools.md` no longer reference it.
143
214
  - **`uam_set_status` documentation.** Doc previously said valid status values include `CLOSED`. The source enum is `NEW`, `IN_PROGRESS`, `RESOLVED`; doc now matches.
144
215
 
145
216
  ### Changed
217
+
146
218
  - **Refactored** dispatch out of `index.js` into `lib/server-core.js` so both transports use one code path. `lib/stdio-transport.js` is the extracted stdio loop; `lib/http-transport.js` is new.
147
219
  - **package.json**:
148
220
  - `version` 1.0.0 → 1.1.0
@@ -150,6 +222,7 @@ Supersedes 1.2.0, which was deprecated on npm. The 1.2.0 build shipped with a st
150
222
  - new files included in the npm tarball: `deploy/`, `scripts/`, `CHANGELOG.md`
151
223
 
152
224
  ### Compatibility
225
+
153
226
  - Default invocation is unchanged: `npx -y @pmoses-s1/s1-secops-mcp` still produces a stdio MCP server with identical behaviour to 1.0.0.
154
227
  - Existing `claude_desktop_config.json` and `.mcp.json` configs work without modification.
155
228
  - The 26 tools, 2 resources, and 2 prompts are unchanged from the late-1.0.0 line; only the documentation now matches reality.
@@ -157,6 +230,7 @@ Supersedes 1.2.0, which was deprecated on npm. The 1.2.0 build shipped with a st
157
230
  ## 1.0.0 - 2026-05-07
158
231
 
159
232
  Initial public release.
233
+
160
234
  - 19 tools across PowerQuery, S1 Mgmt REST, UAM, SDL API, Hyperautomation.
161
235
  - stdio transport only.
162
236
  - Credentials via env vars or auto-discovered `credentials.json`.
package/README.md CHANGED
@@ -43,10 +43,12 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
43
43
  <!-- END AUTO-GENERATED TOOLS TABLE -->
44
44
 
45
45
  **2 resources:**
46
+
46
47
  - `sentinelone://soc-context`: `CLAUDE.md`, the Principal SOC Analyst operating instructions.
47
48
  - `sentinelone://credentials-status`: which credentials are configured and which API surfaces are available.
48
49
 
49
50
  **2 prompts:**
51
+
50
52
  - `soc_analyst`: embeds `CLAUDE.md` as a system prompt; call at session start.
51
53
  - `session_init`: structured init: enumerate sources + triage alerts in parallel.
52
54
 
@@ -65,7 +67,7 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
65
67
  "mcpServers": {
66
68
  "s1-secops-mcp": {
67
69
  "command": "npx",
68
- "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.2"],
70
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.3"],
69
71
  "env": {
70
72
  "S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
71
73
  "S1_CONSOLE_API_TOKEN": "eyJ...",
@@ -204,7 +206,7 @@ If neither env var is set, HTTP transport runs **without** authentication and th
204
206
 
205
207
  Every authenticated HTTP request emits a structured stderr line that systemd captures via journald:
206
208
 
207
- ```
209
+ ```json
208
210
  [audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
209
211
  [audit] 2026-05-28T15:01:34.221Z | bob | tools/list | - | 200 ok
210
212
  [audit] 2026-05-28T17:03:11.221Z | - | - | - | 401 unauthorized
@@ -434,7 +436,7 @@ The `maxRows` (`powerquery_run`) and `first` (`uam_list_alerts`) parameters are
434
436
 
435
437
  ## CLI reference
436
438
 
437
- ```
439
+ ```text
438
440
  s1-secops-mcp [options]
439
441
 
440
442
  OPTIONS
@@ -448,7 +450,7 @@ OPTIONS
448
450
 
449
451
  ## Architecture
450
452
 
451
- ```
453
+ ```text
452
454
  s1-secops-mcp/
453
455
  index.js Entry: flag parsing + transport selection
454
456
  lib/
@@ -485,7 +487,7 @@ s1-secops-mcp/
485
487
  | Purple AI GraphQL | `Authorization: ApiToken <jwt>` | `S1_CONSOLE_API_TOKEN` |
486
488
  | UAM GraphQL | `Authorization: ApiToken <jwt>` | `S1_CONSOLE_API_TOKEN` |
487
489
  | UAM HEC ingest | `Authorization: Bearer <jwt>` | `S1_CONSOLE_API_TOKEN` |
488
- | SDL config files (`POST /sdl/v2/graphql`) | `Authorization: Bearer <jwt>`, do not send `s1-scope` (returns 403) | `S1_CONSOLE_API_TOKEN` |
490
+ | SDL config files (`POST /sdl/v2/graphql`) | `Authorization: Bearer <jwt>`, an `s1-scope` header is ignored, not rejected | `S1_CONSOLE_API_TOKEN` |
489
491
 
490
492
  ## Testing
491
493
 
package/deploy/README.md CHANGED
@@ -21,6 +21,7 @@ bash /tmp/s1-mcp-install.sh --user
21
21
  ```
22
22
 
23
23
  That runs `install.sh --user`, which:
24
+
24
25
  1. Confirms Node 18+ is present (errors out with install hints if not).
25
26
  2. Sets up a per-user npm prefix at `~/.npm-global` if one isn't configured.
26
27
  3. Installs `@pmoses-s1/s1-secops-mcp` globally for your user.
@@ -56,7 +57,7 @@ Or, equivalently, by package name without the install:
56
57
  "mcpServers": {
57
58
  "s1-secops-mcp": {
58
59
  "command": "npx",
59
- "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.2"]
60
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.3"]
60
61
  }
61
62
  }
62
63
  }
@@ -117,11 +118,13 @@ Team members connect from their Claude clients with their own bearer token. Audi
117
118
  1. **Provision the VM.** Anything that runs systemd is fine: Ubuntu 22.04 LTS, Debian 12, Rocky/Alma 9, etc.
118
119
 
119
120
  2. **Install Node 18+.** Pick one:
121
+
120
122
  ```bash
121
123
  # Ubuntu / Debian
122
124
  curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
123
125
  sudo apt install -y nodejs
124
126
  ```
127
+
125
128
  ```bash
126
129
  # Rocky / Alma
127
130
  curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
@@ -129,12 +132,15 @@ Team members connect from their Claude clients with their own bearer token. Audi
129
132
  ```
130
133
 
131
134
  3. **Run the installer in server mode:**
135
+
132
136
  ```bash
133
137
  curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/s1-secops-mcp/deploy/install.sh | sudo bash -s -- --server
134
138
  ```
139
+
135
140
  It creates the `mcp` user, drops `/etc/s1-secops-mcp/credentials.json` (placeholder) and `/etc/s1-secops-mcp/bearer-tokens.json` (one freshly-generated admin token, printed once to stdout), installs the systemd unit, and starts the service.
136
141
 
137
142
  4. **Fill in real SentinelOne credentials:**
143
+
138
144
  ```bash
139
145
  sudo vim /etc/s1-secops-mcp/credentials.json
140
146
  sudo systemctl reload s1-secops-mcp
@@ -142,15 +148,18 @@ Team members connect from their Claude clients with their own bearer token. Audi
142
148
  ```
143
149
 
144
150
  5. **Put TLS in front with Caddy** (the recommended option):
151
+
145
152
  ```bash
146
153
  sudo apt install -y caddy
147
154
  sudo cp /usr/lib/node_modules/@pmoses-s1/s1-secops-mcp/deploy/caddy/Caddyfile.example /etc/caddy/Caddyfile
148
155
  sudo vim /etc/caddy/Caddyfile # change mcp.s1.internal to your DNS name
149
156
  sudo systemctl reload caddy
150
157
  ```
158
+
151
159
  Default Caddyfile uses `tls internal` which signs with Caddy's own CA. Distribute `/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt` to your team for trust, or use `tls <your-email>` with a publicly resolvable hostname for Let's Encrypt.
152
160
 
153
161
  6. **Add team members.** Generate a token per person and append to the file:
162
+
154
163
  ```bash
155
164
  sudo bash -c 'cat > /etc/s1-secops-mcp/bearer-tokens.json' <<EOF
156
165
  {
@@ -164,9 +173,11 @@ Team members connect from their Claude clients with their own bearer token. Audi
164
173
  sudo chown mcp:mcp /etc/s1-secops-mcp/bearer-tokens.json
165
174
  sudo systemctl reload s1-secops-mcp # SIGHUP, no downtime
166
175
  ```
176
+
167
177
  Hand each person their token over a secure channel (1Password, Signal, etc.).
168
178
 
169
179
  7. **Connect from a Claude client.** Each user adds the server to their config with their personal token:
180
+
170
181
  ```json
171
182
  {
172
183
  "mcpServers": {
@@ -182,6 +193,7 @@ Team members connect from their Claude clients with their own bearer token. Audi
182
193
  ```
183
194
 
184
195
  8. **Verify end-to-end.** From a team member's machine:
196
+
185
197
  ```bash
186
198
  curl -s -X POST https://mcp.s1.internal/mcp \
187
199
  -H "Authorization: Bearer $TOKEN" \
@@ -191,6 +203,7 @@ Team members connect from their Claude clients with their own bearer token. Audi
191
203
  ```
192
204
 
193
205
  9. **Watch the audit log.** Every authenticated request is logged with the bearer name, method, and param summary:
206
+
194
207
  ```bash
195
208
  sudo journalctl -u s1-secops-mcp -f | grep '\[audit\]'
196
209
  # [audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
@@ -231,7 +244,7 @@ sudo systemctl restart s1-secops-mcp
231
244
 
232
245
  The structured audit lines look like:
233
246
 
234
- ```
247
+ ```json
235
248
  [audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
236
249
  [audit] 2026-05-28T16:42:55.108Z | bob | tools/list | - | 200 ok
237
250
  [audit] 2026-05-28T17:03:11.221Z | - | - | - | 401 unauthorized
@@ -296,7 +309,7 @@ The instance's `*.compute.internal` DNS name (e.g. `ip-172-31-7-227.ap-southeast
296
309
 
297
310
  If you try to issue a cert for the EC2 public DNS, LE returns:
298
311
 
299
- ```
312
+ ```text
300
313
  HTTP 400 urn:ietf:params:acme:error:rejectedIdentifier
301
314
  The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy
302
315
  ```
@@ -363,7 +376,7 @@ Both block the W+X memory mappings V8 needs to JIT JavaScript. Adding them cause
363
376
 
364
377
  These are supported but not first-class:
365
378
 
366
- - **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.2` + `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.3` + `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`.
367
380
 
368
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.
369
382
 
@@ -76,7 +76,7 @@ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
76
76
 
77
77
  ## How it works
78
78
 
79
- ```
79
+ ```text
80
80
  +----------------+ stdio JSON-RPC +--------+ HTTPS POST /mcp +------+
81
81
  | Claude Desktop | <------------------------------> | bridge | <---------------------------> | VM |
82
82
  +----------------+ +--------+ Bearer auth, JSON in/out +------+
package/lib/sdl.js CHANGED
@@ -133,6 +133,17 @@ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null,
133
133
  // dashboard by udoId. Skipping that rule is how one tenant accumulated 152
134
134
  // copies of `/dashboards/AI Usage`.
135
135
 
136
+ /** Marks an error as originating from the GraphQL layer rather than the
137
+ * transport. Absence detection keys off this: a 404 page whose body contains
138
+ * "not found" must never be read as "the file does not exist". */
139
+ class SdlGraphqlError extends Error {
140
+ constructor(message) {
141
+ super(message);
142
+ this.name = 'SdlGraphqlError';
143
+ this.graphql = true;
144
+ }
145
+ }
146
+
136
147
  /**
137
148
  * POST /sdl/v2/graphql. Returns `data`; throws on the GraphQL `errors` array.
138
149
  *
@@ -170,10 +181,10 @@ async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) {
170
181
  const errs = Array.isArray(payload.errors) ? payload.errors : [payload.errors];
171
182
  const correlationId = payload.extensions?.correlationId ?? errs[0]?.extensions?.correlationId;
172
183
  const msg = errs[0]?.message || 'unknown GraphQL error';
173
- throw new Error(`SDL GraphQL ${opname} → ${msg}${correlationId ? ` (correlationId=${correlationId})` : ''}`);
184
+ throw new SdlGraphqlError(`SDL GraphQL ${opname} → ${msg}${correlationId ? ` (correlationId=${correlationId})` : ''}`);
174
185
  }
175
186
  if (!('data' in payload)) {
176
- throw new Error(`SDL GraphQL ${opname}: response carried neither data nor errors.`);
187
+ throw new SdlGraphqlError(`SDL GraphQL ${opname}: response carried neither data nor errors.`);
177
188
  }
178
189
  return payload.data;
179
190
  }
@@ -191,6 +202,15 @@ function assertSafeUdoId(udoId) {
191
202
 
192
203
  const CONFIG_FIELDS = 'udoId name readOnly version';
193
204
 
205
+ /** SDL config names are case-insensitive and tolerate stray whitespace, so the
206
+ * absence check and the duplicate guard must normalise identically. */
207
+ function normaliseName(n) {
208
+ return String(n ?? '').trim().toLowerCase();
209
+ }
210
+ function matchesName(file, name) {
211
+ return normaliseName(file?.name) === normaliseName(name);
212
+ }
213
+
194
214
  /** Every config file on the tenant, including udoId-addressed dashboards. */
195
215
  export async function configFiles() {
196
216
  const data = await sdlGraphql(
@@ -202,13 +222,58 @@ export async function configFiles() {
202
222
  return data?.configFiles ?? [];
203
223
  }
204
224
 
205
- /** Read one config file by name (plain files) or udoId (dashboards). */
225
+ /**
226
+ * Read one config file by name (plain files) or udoId (dashboards).
227
+ * Returns null when the file does not exist.
228
+ *
229
+ * Absence is a normal outcome of a lookup, but the server reports it as a
230
+ * GraphQL error, and the message differs by address form (verified live):
231
+ *
232
+ * by name : "Config file with name /x/y not found." <- explicit
233
+ * by udoId: "Something went wrong. Please try again..." <- generic, and the
234
+ * SAME text a version conflict returns, so it cannot be trusted
235
+ * on message alone.
236
+ *
237
+ * So the explicit form is normalised directly, and the ambiguous one is
238
+ * disambiguated against the file listing: absent from configFiles means the
239
+ * file is genuinely gone, otherwise the error was real and is rethrown. The
240
+ * extra listing only happens on the error path.
241
+ */
206
242
  export async function configFile({ name, udoId }) {
207
243
  if (!name && !udoId) throw new Error('configFile requires either name or udoId');
208
- const data = udoId
209
- ? await sdlGraphql('configFile', `query f($udoId: ID!) { configFile(udoId: $udoId) { ${CONFIG_FIELDS} content } }`, { udoId: assertSafeUdoId(udoId) }, { readOnly: true })
210
- : await sdlGraphql('configFile', `query f($id: ID!) { configFile(id: $id) { ${CONFIG_FIELDS} content } }`, { id: name }, { readOnly: true });
211
- return data?.configFile ?? null;
244
+ // Validate before the try: an invalid udoId is a caller bug, not a signal
245
+ // that the file is absent, and must never be swallowed by the absence path.
246
+ const safeUdoId = udoId ? assertSafeUdoId(udoId) : null;
247
+ try {
248
+ const data = safeUdoId
249
+ ? await sdlGraphql('configFile', `query f($udoId: ID!) { configFile(udoId: $udoId) { ${CONFIG_FIELDS} content } }`, { udoId: safeUdoId }, { readOnly: true })
250
+ : await sdlGraphql('configFile', `query f($id: ID!) { configFile(id: $id) { ${CONFIG_FIELDS} content } }`, { id: name }, { readOnly: true });
251
+ return data?.configFile ?? null;
252
+ } catch (err) {
253
+ // Only a GraphQL-layer error can mean "absent". A transport failure whose
254
+ // body happens to contain the words "not found" (a 404 page, a WAF block)
255
+ // must never be read as absence: that is how a delete gets confirmed
256
+ // against a file that was never checked.
257
+ if (!err.graphql) throw err;
258
+ if (/config file with (name|id) .* not found/i.test(err.message)) return null;
259
+
260
+ // The udoId form returns a generic message that a version conflict also
261
+ // returns, so settle it against the listing. If the listing itself fails,
262
+ // surface the ORIGINAL error with the listing failure attached rather than
263
+ // replacing it.
264
+ let all;
265
+ try {
266
+ all = await configFiles();
267
+ } catch (listErr) {
268
+ err.message += ` (absence check failed: ${listErr.message})`;
269
+ throw err;
270
+ }
271
+ const present = udoId
272
+ ? all.some(f => String(f.udoId) === String(udoId))
273
+ : all.some(f => matchesName(f, name));
274
+ if (!present) return null;
275
+ throw err;
276
+ }
212
277
  }
213
278
 
214
279
  /**
@@ -222,7 +287,7 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
222
287
  // Creating a dashboard must go by name (no udoId exists yet); only an
223
288
  // *existing* dashboard is at risk of being duplicated by a name-addressed
224
289
  // write. So refuse only when a file of that name already exists.
225
- if (!udoId && String(name).startsWith('/dashboards/')) {
290
+ if (!udoId && normaliseName(name).startsWith('/dashboards/')) {
226
291
  const all = await configFiles();
227
292
  // An empty listing means the check could not run, not that the name is
228
293
  // free. Failing open here would silently disable the guard.
@@ -232,8 +297,7 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
232
297
  'duplicate check could not run. Retry, or pass an explicit udoId.'
233
298
  );
234
299
  }
235
- const key = String(name).trim().toLowerCase();
236
- const existing = all.filter(f => String(f.name || '').trim().toLowerCase() === key);
300
+ const existing = all.filter(f => matchesName(f, name));
237
301
  if (existing.length) {
238
302
  const ids = existing.map(f => f.udoId).filter(Boolean).join(', ');
239
303
  throw new Error(
@@ -307,5 +371,7 @@ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime
307
371
  startTime,
308
372
  };
309
373
  if (endTime) body.endTime = endTime;
310
- return sdlFetch('POST', '/api/query', { body });
374
+ // Read-only POST: opt back into status retry. Schema discovery iterates this
375
+ // once per data source, which is the workload that trips the SDL QPS cap.
376
+ return sdlFetch('POST', '/api/query', { body, allowRetry: true });
311
377
  }
@@ -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.2',
97
+ version: '1.3.3',
98
98
  };
99
99
 
100
100
  export const PROTOCOL_VERSION = '2024-11-05';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmoses-s1/s1-secops-mcp",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
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",
package/tools/sdl-api.js CHANGED
@@ -97,7 +97,7 @@ export const tools = [
97
97
  },
98
98
  expectedVersion: {
99
99
  type: 'number',
100
- description: 'Current file version from sdl_get_file, for optimistic locking. Honoured on udoId-addressed writes. Omit when creating a new file.',
100
+ description: 'Current file version from sdl_get_file, for optimistic locking. Enforced on BOTH address forms, path and udoId: a stale value is rejected and nothing is written. Omit only when creating a new file.',
101
101
  },
102
102
  },
103
103
  required: ['content'],