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

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,135 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.5 - 2026-08-17
4
+
5
+ Completes the scope work in 1.3.4. **Upgrade from 1.3.4 is recommended.**
6
+
7
+ ### Fixed
8
+
9
+ - **Query methods ignored `scope`.** 1.3.4 added the `scope` argument to the
10
+ config-file and dashboard operations but not to the query paths, even though
11
+ log reads are filtered by the same `S1-Scope` header. A hunt or a
12
+ panel-validation query run without the intended scope silently answered for
13
+ the token default, which is the worst shape of this bug: a plausible number
14
+ for the wrong boundary, with no error.
15
+
16
+ Scope now threads through `lrqRun` (launch **and** poll, so the forward-tagged
17
+ follow-ups stay on the same scope) and through all five Python query methods:
18
+ `query`, `power_query`, `facet_query`, `numeric_query`, `timeseries_query`.
19
+ Exposed on the `powerquery_run`, `powerquery_enumerate_sources` and
20
+ `powerquery_schema_discover` tools.
21
+
22
+ Found by using the shipped 1.3.4 client to discover schema at a site scope and
23
+ getting `power_query() got an unexpected keyword argument 'scope'`.
24
+
25
+ - **`scopeHeaders` is now exported from `lib/sdl.js`** and imported by the LRQ
26
+ path in `lib/s1.js`, rather than each surface resolving scope its own way. A
27
+ second implementation would drift, and the validation rules (numeric ids,
28
+ `null` suppresses the default) have to be identical on both.
29
+
30
+ ### Tests
31
+
32
+ - 4 new JS cases: `v1Query` header presence and absence, malformed-scope
33
+ rejection before any request, and a direct contract test on the exported
34
+ `scopeHeaders`.
35
+ - 8 new Python cases covering all five query methods, unscoped omission,
36
+ malformed-scope rejection, and `scope=None` suppression.
37
+ - Totals: 114 JS, 59 Python client, 19 panel-safety. No regressions.
38
+
39
+ ### Docker
40
+
41
+ Bundle image stays **1.3.2**; its npm pin moves to 1.3.5.
42
+
43
+ ## 1.3.4 - 2026-08-17
44
+
45
+ Adds site-level dashboard lifecycle. Two gaps closed: SDL GraphQL calls never
46
+ sent an `S1-Scope` header, and the dashboard operations the console itself uses
47
+ were not wrapped at all. **26 tools → 32.**
48
+
49
+ ### Added
50
+
51
+ - **`S1-Scope` on every SDL GraphQL and V1-query call.** Optional `scope`
52
+ argument on `configFiles`, `configFile`, `putConfigFile`, `deleteConfigFile`
53
+ and `v1Query`, and on the four `sdl_*_file` tools. Falls back to a new
54
+ `S1_SCOPE` credential; `scope: null` suppresses that default and sends no
55
+ header. Format `"<accountId>"` or `"<accountId>:<siteId>"`, validated before
56
+ the request so a typo cannot silently widen the read.
57
+ - **Six dashboard-lifecycle tools** on the `dashboardsV2` surface:
58
+ `sdl_list_dashboards`, `sdl_get_dashboard`, `sdl_create_dashboard`,
59
+ `sdl_share_dashboard`, `sdl_save_dashboard_layout`, `sdl_delete_dashboard`.
60
+ - **`sdl_create_dashboard`** takes the whole dashboard document as one `config`
61
+ string, the path the console uses. It parses the JSON first, so the UI
62
+ stub-append failure (`{graphs: []}{...}` → "Content is invalid json /
63
+ Additional text after JSON object", leaving an empty dashboard behind) is
64
+ reported as a caller error instead of filing a broken shell.
65
+ - **`sdl_share_dashboard`** wraps `shareResource`, the only SDL operation that
66
+ takes an explicit scope target. This is how an account-scoped dashboard is
67
+ pushed to a site without recreating it. Scope targets are validated up front,
68
+ because the server accepts a malformed entry, shares nothing, and reports
69
+ success.
70
+ - **`S1_SCOPE`** added to `getCreds()`. It was absent, so any credentials-file
71
+ default would have been read and then dropped.
72
+
73
+ ### Fixed
74
+
75
+ - **Scope-sensitive call sites now scope consistently.** Absence
76
+ disambiguation re-lists at the scope of the failed lookup, the `/dashboards/`
77
+ duplicate guard lists at the scope of the write, and delete verification
78
+ re-reads at the scope of the delete. Mixing scopes across these steps reports
79
+ a live site-scoped file as deleted, which is the same false-negative class
80
+ 1.3.3 fixed for error text.
81
+
82
+ ### Corrected documentation
83
+
84
+ - `sdl-api/references/config-file-graphql.md` claimed the `s1-scope` header was
85
+ "ignored, not rejected" on `/sdl/v2/graphql`. **That was wrong.** Measured on
86
+ `usea1-purple`, same token and query: `configFiles` returned 113 files at
87
+ account scope and 4 at a site scope. Config listings and dashboard reads are
88
+ scope-FILTERED, so a dropped header changes which objects appear to exist.
89
+ `auth_and_limits.md` corrected to match.
90
+
91
+ ### Dashboard skill
92
+
93
+ - **`site.id`, not `site.name`, is the scoping predicate.** For one site over
94
+ 24h, `site.id='<id>'` matched 60,410 events of which 510 carried the site id
95
+ with a null `site.name`: `ActivityFeed` 172, `asset` 111, unattributed 99,
96
+ `SentinelOne` 70, `Windows Event Logs` 48, `alert` 10. A `site.name` filter
97
+ silently drops alert and asset records. `site.id` is also the same value as
98
+ the `S1-Scope` `siteId` and survives a site rename.
99
+ - **New scope doctrine in `sdl-dashboard/SKILL.md`:** deployment scope and query
100
+ scope are separate decisions, and a site-deployed dashboard scopes its panels
101
+ to that site unless the user explicitly asks for account-wide queries.
102
+ - **`panel_safety_check.py --site-id <id>`** adds rule **S01** (query panel with
103
+ no, or wrong, `site.id` predicate on a site-targeted dashboard; opt out with
104
+ `--allow-account-scope-queries`) and rule **S02** (`site.name` used as a
105
+ scoping filter; never suppressed, the substitution is wrong at any scope).
106
+ - Recorded that the console's XDR selector injects
107
+ `preFilter: "dataSource.category = 'security'"` into every panel query.
108
+
109
+ ### Python client (`sdl-api/scripts/sdl_client.py`)
110
+
111
+ - Per-call `scope` on `config_files`, `config_file`, `put_config_file`,
112
+ `delete_config_file`, with the same `_UNSET`-vs-`None` distinction.
113
+ - Six new methods mirroring the JS layer: `list_dashboards`, `get_dashboard`,
114
+ `create_dashboard`, `share_dashboard`, `save_dashboard_layout`,
115
+ `delete_dashboard`.
116
+ - `get_dashboard` treats both a null result and a GraphQL error as absence,
117
+ disambiguated against the listing. Assuming only the null form is what broke
118
+ every delete in 1.3.2; the confirming re-read threw on the success path.
119
+
120
+ ### Tests
121
+
122
+ - 24 new cases in `tests/sdl-graphql.test.mjs`: header presence and absence,
123
+ credentials fallback, `scope: null` suppression, malformed-scope rejection
124
+ before any request, scope consistency across the guard / disambiguation /
125
+ delete-verify paths, the six dashboard operations, stub-append rejection, and
126
+ the `getDashboard` absence matrix.
127
+ - New `sdl-dashboard/tests/test_panel_safety_check.py`: 19 cases over S01 and
128
+ S02 including the wrong-site case, the opt-out flag, exempt panel types, and
129
+ a regression guard that existing rules still fire.
130
+ - Tool-count assertions updated 26 → 32 across the smoke, stdio, HTTP and
131
+ origin-guard suites.
132
+
3
133
  ## 1.3.3 - 2026-08-07
4
134
 
5
135
  Fixes a user-facing regression in 1.3.2 found by running the live MCP tools
package/README.md CHANGED
@@ -10,7 +10,7 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
10
10
  ## What this exposes
11
11
 
12
12
  <!-- BEGIN AUTO-GENERATED TOOLS TABLE -->
13
- **26 tools** across PowerQuery, Mgmt Console, SDL API, Hyperautomation, and UAM Ingest:
13
+ **32 tools** across PowerQuery, Mgmt Console, SDL API, Hyperautomation, and UAM Ingest:
14
14
 
15
15
  | Group | Tool | Skill |
16
16
  |-------|------|-------|
@@ -28,10 +28,16 @@ See **[deploy/README.md](./deploy/README.md)** for the full deployment walkthrou
28
28
  | Mgmt Console | `uam_list_alerts` | mgmt-console-api |
29
29
  | Mgmt Console | `uam_set_status` | mgmt-console-api |
30
30
  | SDL API | `hec_ingest` | sdl-api / sdl-log-parser |
31
+ | SDL API | `sdl_create_dashboard` | sdl-api / sdl-dashboard |
32
+ | SDL API | `sdl_delete_dashboard` | sdl-api / sdl-dashboard |
31
33
  | SDL API | `sdl_delete_file` | sdl-api |
34
+ | SDL API | `sdl_get_dashboard` | sdl-api / sdl-dashboard |
32
35
  | SDL API | `sdl_get_file` | sdl-api / sdl-dashboard / sdl-log-parser |
36
+ | SDL API | `sdl_list_dashboards` | sdl-api / sdl-dashboard |
33
37
  | SDL API | `sdl_list_files` | sdl-api / sdl-dashboard / sdl-log-parser |
34
38
  | SDL API | `sdl_put_file` | sdl-api / sdl-dashboard / sdl-log-parser |
39
+ | SDL API | `sdl_save_dashboard_layout` | sdl-api / sdl-dashboard |
40
+ | SDL API | `sdl_share_dashboard` | sdl-api / sdl-dashboard |
35
41
  | Hyperautomation | `ha_delete_workflow` | hyperautomation |
36
42
  | Hyperautomation | `ha_export_workflow` | hyperautomation |
37
43
  | Hyperautomation | `ha_get_workflow` | hyperautomation |
@@ -67,11 +73,11 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
67
73
  "mcpServers": {
68
74
  "s1-secops-mcp": {
69
75
  "command": "npx",
70
- "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.3"],
76
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.5"],
71
77
  "env": {
72
78
  "S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
73
79
  "S1_CONSOLE_API_TOKEN": "eyJ...",
74
- "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net",
80
+ "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net"
75
81
  }
76
82
  }
77
83
  }
@@ -487,7 +493,7 @@ s1-secops-mcp/
487
493
  | Purple AI GraphQL | `Authorization: ApiToken <jwt>` | `S1_CONSOLE_API_TOKEN` |
488
494
  | UAM GraphQL | `Authorization: ApiToken <jwt>` | `S1_CONSOLE_API_TOKEN` |
489
495
  | UAM HEC ingest | `Authorization: Bearer <jwt>` | `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` |
496
+ | SDL config files (`POST /sdl/v2/graphql`) | `Authorization: Bearer <jwt>`, plus an `s1-scope` header that IS honoured: listings and reads are scope-filtered (measured 113 files at account scope vs 4 at a site scope) | `S1_CONSOLE_API_TOKEN`, optional `S1_SCOPE` |
491
497
 
492
498
  ## Testing
493
499
 
@@ -497,7 +503,7 @@ npm test
497
503
 
498
504
  Three test suites under `tests/`:
499
505
 
500
- - `smoke.test.mjs`: introspects `ALL_TOOLS` directly, no spawning. Asserts 26 tools by name; catches any drift between code and the README regenerator.
506
+ - `smoke.test.mjs`: introspects `ALL_TOOLS` directly, no spawning. Asserts 32 tools by name; catches any drift between code and the README regenerator.
501
507
  - `stdio-transport.test.mjs`: spawns the server in stdio mode, exercises `initialize`, `tools/list`, `resources/list`, `prompts/list`, and error handling.
502
508
  - `http-transport.test.mjs`: spawns in HTTP mode on a random ephemeral port, exercises `/healthz`, `POST /mcp`, both auth-required and auth-optional flows, and the env-var token fallback.
503
509
 
package/deploy/README.md CHANGED
@@ -34,7 +34,7 @@ Then edit `~/.config/sentinelone/credentials.json` with your real values:
34
34
  {
35
35
  "S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
36
36
  "S1_CONSOLE_API_TOKEN": "eyJ...",
37
- "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net",
37
+ "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net"
38
38
  }
39
39
  ```
40
40
 
@@ -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.3"]
60
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.5"]
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.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`.
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.5` + `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
 
@@ -46,7 +46,7 @@ Edit `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS,
46
46
 
47
47
  `MCP_URL` is whatever URL your team's admin gave you (it should end in `/mcp`). `MCP_BEARER` is your personal bearer token from `/etc/s1-secops-mcp/bearer-tokens.json` on the VM.
48
48
 
49
- Quit Claude Desktop fully (Cmd+Q on macOS, not just close the window) and reopen. The 26 tools should appear in the tools list.
49
+ Quit Claude Desktop fully (Cmd+Q on macOS, not just close the window) and reopen. The 32 tools should appear in the tools list.
50
50
 
51
51
  ## Smoke test (without Claude Desktop)
52
52
 
@@ -33,7 +33,7 @@
33
33
  * MCP_URL=... MCP_BEARER=... bash -c '
34
34
  * echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}" \
35
35
  * | node s1-secops-mcp-bridge.mjs'
36
- * # -> JSON-RPC response with 26 tools in result.tools[]
36
+ * # -> JSON-RPC response with 32 tools in result.tools[]
37
37
  */
38
38
 
39
39
  import { createInterface } from 'node:readline';
package/index.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Streamable HTTP, using raw JSON-RPC 2.0 throughout. No external runtime
7
7
  * dependencies. Pure Node.js 18+.
8
8
  *
9
- * Exposes 26 tools across PowerQuery, Mgmt Console REST, UAM, SDL API,
9
+ * Exposes 32 tools across PowerQuery, Mgmt Console REST, UAM, SDL API,
10
10
  * Hyperautomation, and UAM Ingest; plus 2 resources and 2 prompts.
11
11
  *
12
12
  * Quick start:
@@ -106,6 +106,12 @@ 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
+ // Default S1-Scope for SDL requests: "<accountId>" for account scope or
110
+ // "<accountId>:<siteId>" for site scope. Optional. Per-call scope arguments
111
+ // override it; passing scope:null suppresses it entirely. SDL reads are
112
+ // scope-FILTERED, so this value decides which config files and dashboards a
113
+ // session can see at all, not merely where new ones are filed.
114
+ S1_SCOPE: e('S1_SCOPE'),
109
115
  };
110
116
  }
111
117
 
package/lib/s1.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { getCreds } from './credentials.js';
12
+ import { scopeHeaders } from './sdl.js';
12
13
 
13
14
  // ─── helpers ──────────────────────────────────────────────────────────────────
14
15
 
@@ -191,7 +192,7 @@ export function pickMatchCount(result) {
191
192
  }
192
193
 
193
194
  /** Run a full LRQ PowerQuery lifecycle. Returns { columns, rows, rowCount, matchCount }. */
194
- export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows = 5000 } = {}) {
195
+ export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows = 5000, scope } = {}) {
195
196
  const b = base();
196
197
  const tok = jwt();
197
198
 
@@ -207,12 +208,17 @@ export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows =
207
208
  pq: { query, resultType: 'TABLE' },
208
209
  };
209
210
 
211
+ // S1-Scope applies to log reads exactly as it does to config reads: an LRQ
212
+ // run without the intended scope silently answers for the token default.
213
+ const scopeHdrs = scopeHeaders(scope);
214
+
210
215
  // Launch
211
216
  const launchRes = await fetch(launchUrl, {
212
217
  method: 'POST',
213
218
  headers: {
214
219
  Authorization: `Bearer ${tok}`,
215
220
  'Content-Type': 'application/json',
221
+ ...scopeHdrs,
216
222
  },
217
223
  body: JSON.stringify(launchBody),
218
224
  });
@@ -230,6 +236,7 @@ export async function lrqRun(query, { startTime, endTime, hours = 24, maxRows =
230
236
  const pollHeaders = {
231
237
  Authorization: `Bearer ${tok}`,
232
238
  'Content-Type': 'application/json',
239
+ ...scopeHdrs,
233
240
  ...(forwardTag ? { 'X-Dataset-Query-Forward-Tag': forwardTag } : {}),
234
241
  };
235
242
 
package/lib/sdl.js CHANGED
@@ -26,6 +26,50 @@ export function sdlToken() {
26
26
  return token;
27
27
  }
28
28
 
29
+ /**
30
+ * Resolve the S1-Scope header value for a request.
31
+ *
32
+ * SDL objects (dashboards, saved searches, config files) are filed against the
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
35
+ * account scope (20 of them dashboards) and 4 at a site scope (all 4
36
+ * dashboards). A dashboard created at site scope is invisible to an
37
+ * account-scoped listing, so a missing header is not a neutral default, it
38
+ * silently changes which objects exist as far as the caller can tell.
39
+ *
40
+ * Precedence: explicit per-call scope, then S1_SCOPE from credentials. Passing
41
+ * `null` is NOT the same as omitting: `null` deliberately suppresses the creds
42
+ * default and sends no header, which is what the account-wide listing needs.
43
+ *
44
+ * Format: "<accountId>" for account scope, "<accountId>:<siteId>" for site
45
+ * scope. Group scope does not exist in SDL; the console silently promotes a
46
+ * Group selection to the Site above it.
47
+ */
48
+ function resolveScope(scope) {
49
+ if (scope === null) return null;
50
+ const raw = scope !== undefined ? scope : getCreds().S1_SCOPE;
51
+ if (raw === undefined || raw === null || raw === '') return null;
52
+ if (typeof raw !== 'string') {
53
+ throw new Error(`S1-Scope must be a string, got ${typeof raw}. Use "<accountId>" or "<accountId>:<siteId>".`);
54
+ }
55
+ const trimmed = raw.trim();
56
+ if (!/^\d+(:\d+)?$/.test(trimmed)) {
57
+ throw new Error(
58
+ `Invalid S1-Scope ${JSON.stringify(trimmed)}. Expected "<accountId>" or "<accountId>:<siteId>", ` +
59
+ 'both numeric ids. Get them from GET /web/api/v2.1/accounts and /web/api/v2.1/sites.'
60
+ );
61
+ }
62
+ return trimmed;
63
+ }
64
+
65
+ /** Header object carrying S1-Scope, or empty when the request is unscoped.
66
+ * Exported so the LRQ path in lib/s1.js scopes identically; a second
67
+ * implementation would drift. */
68
+ export function scopeHeaders(scope) {
69
+ const resolved = resolveScope(scope);
70
+ return resolved ? { 'S1-Scope': resolved } : {};
71
+ }
72
+
29
73
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
30
74
 
31
75
  function retryAfterMs(res, fallback) {
@@ -160,14 +204,15 @@ class SdlGraphqlError extends Error {
160
204
  * 3. Return a payload carrying neither `data` nor `errors`.
161
205
  *
162
206
  * `readOnly` opts into status-based retry; only pass it for queries.
207
+ * `scope` sets the S1-Scope header; see resolveScope for precedence.
163
208
  */
164
- async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) {
209
+ async function sdlGraphql(opname, query, variables, { readOnly = false, scope } = {}) {
165
210
  const body = { query };
166
211
  if (variables) body.variables = variables;
167
212
  const payload = await sdlFetch(
168
213
  'POST',
169
214
  `/v2/graphql?opname=${encodeURIComponent(opname)}`,
170
- { body, allowRetry: readOnly }
215
+ { body, allowRetry: readOnly, extraHeaders: scopeHeaders(scope) }
171
216
  );
172
217
 
173
218
  if (typeof payload !== 'object' || payload === null) {
@@ -211,13 +256,15 @@ function matchesName(file, name) {
211
256
  return normaliseName(file?.name) === normaliseName(name);
212
257
  }
213
258
 
214
- /** Every config file on the tenant, including udoId-addressed dashboards. */
215
- export async function configFiles() {
259
+ /** Every config file visible at `scope`, including udoId-addressed dashboards.
260
+ * This listing IS scope-filtered: a site-scoped dashboard does not appear in an
261
+ * account-scoped listing and vice versa. */
262
+ export async function configFiles({ scope } = {}) {
216
263
  const data = await sdlGraphql(
217
264
  'getConfigurationFiles',
218
265
  `query getConfigurationFiles { configFiles { ${CONFIG_FIELDS} } }`,
219
266
  undefined,
220
- { readOnly: true }
267
+ { readOnly: true, scope }
221
268
  );
222
269
  return data?.configFiles ?? [];
223
270
  }
@@ -239,15 +286,15 @@ export async function configFiles() {
239
286
  * file is genuinely gone, otherwise the error was real and is rethrown. The
240
287
  * extra listing only happens on the error path.
241
288
  */
242
- export async function configFile({ name, udoId }) {
289
+ export async function configFile({ name, udoId, scope }) {
243
290
  if (!name && !udoId) throw new Error('configFile requires either name or udoId');
244
291
  // Validate before the try: an invalid udoId is a caller bug, not a signal
245
292
  // that the file is absent, and must never be swallowed by the absence path.
246
293
  const safeUdoId = udoId ? assertSafeUdoId(udoId) : null;
247
294
  try {
248
295
  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 });
296
+ ? await sdlGraphql('configFile', `query f($udoId: ID!) { configFile(udoId: $udoId) { ${CONFIG_FIELDS} content } }`, { udoId: safeUdoId }, { readOnly: true, scope })
297
+ : await sdlGraphql('configFile', `query f($id: ID!) { configFile(id: $id) { ${CONFIG_FIELDS} content } }`, { id: name }, { readOnly: true, scope });
251
298
  return data?.configFile ?? null;
252
299
  } catch (err) {
253
300
  // Only a GraphQL-layer error can mean "absent". A transport failure whose
@@ -261,9 +308,13 @@ export async function configFile({ name, udoId }) {
261
308
  // returns, so settle it against the listing. If the listing itself fails,
262
309
  // surface the ORIGINAL error with the listing failure attached rather than
263
310
  // replacing it.
311
+ // Same scope as the failed lookup. Disambiguating against a DIFFERENT
312
+ // scope's listing would report a site-scoped file as absent purely because
313
+ // the listing was taken at account scope, which is the exact false-negative
314
+ // class this branch exists to remove.
264
315
  let all;
265
316
  try {
266
- all = await configFiles();
317
+ all = await configFiles({ scope });
267
318
  } catch (listErr) {
268
319
  err.message += ` (absence check failed: ${listErr.message})`;
269
320
  throw err;
@@ -282,13 +333,16 @@ export async function configFile({ name, udoId }) {
282
333
  * - name given → updates in place for plain files, but CREATES A DUPLICATE
283
334
  * for /dashboards/. Never write a dashboard by name.
284
335
  */
285
- export async function putConfigFile({ name, udoId, content, expectedVersion }) {
336
+ export async function putConfigFile({ name, udoId, content, expectedVersion, scope }) {
286
337
  if (!name && !udoId) throw new Error('putConfigFile requires either name or udoId');
287
338
  // Creating a dashboard must go by name (no udoId exists yet); only an
288
339
  // *existing* dashboard is at risk of being duplicated by a name-addressed
289
340
  // write. So refuse only when a file of that name already exists.
290
341
  if (!udoId && normaliseName(name).startsWith('/dashboards/')) {
291
- const all = await configFiles();
342
+ // Scoped to match the write. An account-scoped listing would not see a
343
+ // same-named site-scoped dashboard, so the guard has to look where the
344
+ // write is going, not where the token defaults.
345
+ const all = await configFiles({ scope });
292
346
  // An empty listing means the check could not run, not that the name is
293
347
  // free. Failing open here would silently disable the guard.
294
348
  if (!all.length) {
@@ -315,10 +369,10 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
315
369
  const data = udoId
316
370
  ? await sdlGraphql('addConfigFile',
317
371
  `mutation f($udoId: ID, $content: String!, $expectedVersion: Long) { addConfigFile(udoId: $udoId, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
318
- { udoId: assertSafeUdoId(udoId), content, expectedVersion })
372
+ { udoId: assertSafeUdoId(udoId), content, expectedVersion }, { scope })
319
373
  : await sdlGraphql('addConfigFile',
320
374
  `mutation f($name: String, $content: String!, $expectedVersion: Long) { addConfigFile(name: $name, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
321
- { name, content, expectedVersion });
375
+ { name, content, expectedVersion }, { scope });
322
376
  return data?.addConfigFile ?? null;
323
377
  }
324
378
 
@@ -327,21 +381,21 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
327
381
  * A null return with no errors array is SUCCESS; the deleted object is not
328
382
  * echoed back. Treating that null as a failure is the classic mistake here.
329
383
  */
330
- export async function deleteConfigFile({ name, udoId, expectedVersion }) {
384
+ export async function deleteConfigFile({ name, udoId, expectedVersion, scope }) {
331
385
  if (!name && !udoId) throw new Error('deleteConfigFile requires either name or udoId');
332
386
  const raw = udoId
333
387
  ? await sdlGraphql('deleteConfigFile',
334
388
  'mutation f($udoId: ID, $expectedVersion: Long) { deleteConfigFile(udoId: $udoId, expectedVersion: $expectedVersion) { udoId } }',
335
- { udoId: assertSafeUdoId(udoId), expectedVersion })
389
+ { udoId: assertSafeUdoId(udoId), expectedVersion }, { scope })
336
390
  : await sdlGraphql('deleteConfigFile',
337
391
  'mutation f($id: ID, $expectedVersion: Long) { deleteConfigFile(id: $id, expectedVersion: $expectedVersion) { udoId } }',
338
- { id: name, expectedVersion });
392
+ { id: name, expectedVersion }, { scope });
339
393
 
340
394
  // The mutation returns null on success and does not echo the deleted object,
341
395
  // so its response cannot distinguish "deleted" from "matched nothing". Confirm
342
396
  // by re-reading. This is the house rule established by uamSetStatus in
343
397
  // lib/s1.js: never treat a mutation response as proof, re-get and verify.
344
- const still = await configFile({ name, udoId });
398
+ const still = await configFile({ name, udoId, scope });
345
399
  if (still) {
346
400
  throw new Error(
347
401
  `deleteConfigFile: ${udoId ? `udoId ${udoId}` : name} still exists after the delete mutation ` +
@@ -357,13 +411,275 @@ export async function deleteConfigFile({ name, udoId, expectedVersion }) {
357
411
  // exist" decision. The GraphQL operations above cover every namespace,
358
412
  // including parsers, lookups, datatables and /automaticLookups.
359
413
 
414
+ // ─── Dashboard lifecycle (dashboardsV2, GraphQL) ──────────────────────────────
415
+ //
416
+ // A SECOND, HIGHER-LEVEL SURFACE on the same `POST /sdl/v2/graphql` endpoint.
417
+ // This is what the console itself drives; captured from live console traffic on
418
+ // usea1-purple 2026-08-17 (280 requests, 23 operations).
419
+ //
420
+ // Relationship to the config-file layer above:
421
+ //
422
+ // dashboardsV2 dashboard-aware: name, description, tabs, access/sharing,
423
+ // createdBy/updatedBy, isBuiltIn/isEditable. Create takes
424
+ // the whole dashboard JSON as one `config` string.
425
+ // configFiles the raw file underneath, addressed by udoId. Same object,
426
+ // no sharing or authorship metadata, `content` is the JSON.
427
+ //
428
+ // The `id` in dashboardsV2 IS the `udoId` in configFiles. Verified: dashboard
429
+ // "meta1" is id 6999000578736128 in getDashboardV2 and udoId 6999000578736128
430
+ // / name "/dashboards/meta1" in configFile.
431
+ //
432
+ // WHY THIS EXISTS: creating a dashboard through addConfigFile(name:) files it at
433
+ // the request's scope but gives no way to share it elsewhere, and the console's
434
+ // own create path is createDashboardV2. Site-level lifecycle needs both this and
435
+ // shareResource, which is the ONLY operation that takes an explicit scope target
436
+ // rather than inferring one from the request header.
437
+ //
438
+ // VERSION FIELDS DIFFER, do not cross them. getDashboardV2 returns
439
+ // `version: ""` (a display string, empty in practice); configFile returns
440
+ // `version: 215771284` (the numeric CAS token). Only the configFile value is
441
+ // valid as expectedVersion.
442
+
443
+ const DASHBOARD_SUMMARY_FIELDS = 'id name description configType access { public users owner }';
444
+
445
+ /** Every dashboard visible at `scope`, with sharing metadata. Prefer this over
446
+ * configFiles({pathPrefix:'/dashboards/'}) when you need owner or access. */
447
+ export async function listDashboards({ scope } = {}) {
448
+ const data = await sdlGraphql(
449
+ 'GetDashboardNames',
450
+ `query GetDashboardNames { dashboardsV2 { ${DASHBOARD_SUMMARY_FIELDS} } }`,
451
+ undefined,
452
+ { readOnly: true, scope }
453
+ );
454
+ return data?.dashboardsV2 ?? [];
455
+ }
456
+
457
+ /**
458
+ * Read one dashboard by id (preferred) or name, including its tabs.
459
+ * Returns null when it does not exist at `scope`.
460
+ *
461
+ * `tabs[].graphs` / `.parameters` / `.filters` / `.options` come back as JSON
462
+ * STRINGS, not objects; the console parses them client-side. Callers that want
463
+ * structure must JSON.parse each one.
464
+ *
465
+ * ABSENCE HANDLING mirrors configFile deliberately. The live capture only shows
466
+ * a successful read, so it is not established whether a missing dashboard comes
467
+ * back as `data.getDashboardV2 = null` or as a GraphQL error. Both are treated
468
+ * as absence, disambiguated against the dashboard listing. Assuming only the
469
+ * null form is what broke every `sdl_delete_file` in 1.3.2: the confirming
470
+ * re-read threw on precisely the success path. A transport-layer error is still
471
+ * rethrown, so a proxy page containing "not found" can never be read as absence.
472
+ */
473
+ export async function getDashboard({ id, name, scope }) {
474
+ if (!id && !name) throw new Error('getDashboard requires either id or name');
475
+ const safeId = id ? assertSafeUdoId(id) : undefined;
476
+ try {
477
+ const data = await sdlGraphql(
478
+ 'GetDashboardConfigV2',
479
+ `query GetDashboardConfigV2($id: ID, $dashboardName: String) {
480
+ getDashboardV2(id: $id, dashboardName: $dashboardName, resolveParameters: true) {
481
+ id name description configType duration isBuiltIn isEditable version
482
+ access { public users owner }
483
+ tabs { tabName parameters graphs filters options }
484
+ createdAt createdBy updatedAt updatedBy
485
+ }
486
+ }`,
487
+ { id: safeId, dashboardName: name },
488
+ { readOnly: true, scope }
489
+ );
490
+ return data?.getDashboardV2 ?? null;
491
+ } catch (err) {
492
+ if (!err.graphql) throw err;
493
+ let all;
494
+ try {
495
+ all = await listDashboards({ scope });
496
+ } catch (listErr) {
497
+ err.message += ` (absence check failed: ${listErr.message})`;
498
+ throw err;
499
+ }
500
+ const present = safeId
501
+ ? all.some(d => String(d.id) === safeId)
502
+ : all.some(d => normaliseName(d.name) === normaliseName(name));
503
+ if (!present) return null;
504
+ throw err;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Create a dashboard from a full dashboard-JSON config, at `scope`.
510
+ *
511
+ * This is the console's own create path and it accepts the complete dashboard
512
+ * document (configType, duration, description, tabs[]) as ONE string. That is
513
+ * the important difference from the UI's "new dashboard then paste JSON" flow,
514
+ * which starts from a `{graphs: []}` stub: pasting after the stub instead of
515
+ * replacing it produces `{graphs: []}{...}` and the server rejects it with
516
+ * "Content is invalid json" / "Additional text after JSON object". Going
517
+ * through this function cannot hit that class of error.
518
+ *
519
+ * DUPLICATE NAMES ARE ALLOWED HERE, unlike putConfigFile. The console itself
520
+ * creates "<name> - Copy" siblings, and shareResource addresses dashboards by
521
+ * id, so duplicate names are not the footgun they are for name-addressed
522
+ * config-file writes. Set `failIfNameExists` to opt into the stricter
523
+ * behaviour; it costs one extra listing call.
524
+ */
525
+ export async function createDashboard({ name, config, isPublic = false, scope, failIfNameExists = false }) {
526
+ if (!name || typeof name !== 'string') throw new Error('createDashboard requires a name');
527
+ if (typeof config !== 'string' || !config.trim()) {
528
+ throw new Error('createDashboard requires config as a JSON string (the full dashboard document).');
529
+ }
530
+ // Fail before the mutation rather than filing a broken dashboard the console
531
+ // then renders as an empty shell.
532
+ try {
533
+ JSON.parse(config);
534
+ } catch (e) {
535
+ throw new Error(
536
+ `createDashboard: config is not valid JSON (${e.message}). ` +
537
+ 'If this came from the console\'s JSON editor, check for a leading "{graphs: []}" stub: ' +
538
+ 'the new document must REPLACE it, not follow it.'
539
+ );
540
+ }
541
+
542
+ if (failIfNameExists) {
543
+ const existing = (await listDashboards({ scope })).filter(d => normaliseName(d.name) === normaliseName(name));
544
+ if (existing.length) {
545
+ throw new Error(
546
+ `createDashboard: ${existing.length} dashboard(s) named "${name}" already exist at this scope ` +
547
+ `(ids: ${existing.map(d => d.id).join(', ')}). Pass failIfNameExists:false to create a sibling anyway.`
548
+ );
549
+ }
550
+ }
551
+
552
+ const data = await sdlGraphql(
553
+ 'CreateDashboard',
554
+ `mutation CreateDashboard($dashboardName: String!, $config: String, $public: Boolean) {
555
+ createDashboardV2(dashboardName: $dashboardName, config: $config, public: $public) { id name }
556
+ }`,
557
+ { dashboardName: name, config, public: isPublic },
558
+ { scope }
559
+ );
560
+ const created = data?.createDashboardV2 ?? null;
561
+ if (!created?.id) {
562
+ throw new SdlGraphqlError('createDashboard: mutation returned no id, so the dashboard was not created.');
563
+ }
564
+ return created;
565
+ }
566
+
567
+ /**
568
+ * Share a dashboard to scopes and/or users. THE ONLY OPERATION THAT TAKES AN
569
+ * EXPLICIT SCOPE TARGET; everything else infers scope from the S1-Scope header.
570
+ *
571
+ * `scopes` entries are {scopeType, scopeId, operation}:
572
+ * scopeType 'site' | 'account' | 'global'
573
+ * scopeId the numeric id from /web/api/v2.1/sites or /accounts
574
+ * operation 'ADD' | 'REMOVE'
575
+ *
576
+ * `scope` (the option, not the array) is still the header for the CALL, i.e.
577
+ * where you are standing when you share. It is independent of the targets.
578
+ */
579
+ const VALID_SCOPE_TYPES = new Set(['site', 'account', 'global']);
580
+ const VALID_SCOPE_OPS = new Set(['ADD', 'REMOVE']);
581
+
582
+ export async function shareDashboard({ id, scopes = [], users = [], scope }) {
583
+ if (!id) throw new Error('shareDashboard requires the dashboard id');
584
+ if (!Array.isArray(scopes) || !Array.isArray(users)) {
585
+ throw new Error('shareDashboard: scopes and users must both be arrays.');
586
+ }
587
+ if (!scopes.length && !users.length) {
588
+ throw new Error('shareDashboard: pass at least one scope or user, otherwise the call is a no-op.');
589
+ }
590
+ // Validate up front: the server accepts a malformed entry and silently shares
591
+ // nothing, which reads as success.
592
+ const normalisedScopes = scopes.map((s, i) => {
593
+ const type = String(s?.scopeType ?? '').toLowerCase();
594
+ const op = String(s?.operation ?? 'ADD').toUpperCase();
595
+ if (!VALID_SCOPE_TYPES.has(type)) {
596
+ throw new Error(`shareDashboard: scopes[${i}].scopeType must be one of ${[...VALID_SCOPE_TYPES].join(', ')} (got ${JSON.stringify(s?.scopeType)}).`);
597
+ }
598
+ if (!VALID_SCOPE_OPS.has(op)) {
599
+ throw new Error(`shareDashboard: scopes[${i}].operation must be ADD or REMOVE (got ${JSON.stringify(s?.operation)}).`);
600
+ }
601
+ if (type !== 'global' && !/^\d+$/.test(String(s?.scopeId ?? ''))) {
602
+ throw new Error(`shareDashboard: scopes[${i}].scopeId must be a numeric id for scopeType "${type}" (got ${JSON.stringify(s?.scopeId)}).`);
603
+ }
604
+ return { scopeType: type, scopeId: String(s.scopeId), operation: op };
605
+ });
606
+
607
+ const data = await sdlGraphql(
608
+ 'ShareDashboard',
609
+ `mutation ShareDashboard($id: ID!, $users: [UserSharingCommand], $scopes: [ScopeSharingCommand]) {
610
+ shareResource(id: $id, users: $users, scopes: $scopes) { id name }
611
+ }`,
612
+ { id: assertSafeUdoId(id), users, scopes: normalisedScopes },
613
+ { scope }
614
+ );
615
+ const shared = data?.shareResource ?? null;
616
+ if (!shared?.id) {
617
+ throw new SdlGraphqlError('shareDashboard: shareResource returned no id, so nothing was shared.');
618
+ }
619
+ return { status: 'success', dashboard: { id: String(shared.id), name: shared.name }, scopes: normalisedScopes, users };
620
+ }
621
+
622
+ /**
623
+ * Replace the panel layout of ONE tab. `graphs` is a JSON string shaped
624
+ * `{"graphs":[...]}` (note the wrapper key; the response echoes a bare array).
625
+ * Use this for incremental panel edits; use createDashboard for a whole document.
626
+ */
627
+ export async function saveDashboardLayout({ id, name, tabName, graphs, options, scope }) {
628
+ if (!id && !name) throw new Error('saveDashboardLayout requires either id or name');
629
+ if (typeof graphs !== 'string' || !graphs.trim()) {
630
+ throw new Error('saveDashboardLayout requires graphs as a JSON string, shaped {"graphs":[...]}.');
631
+ }
632
+ try {
633
+ const parsed = JSON.parse(graphs);
634
+ if (!parsed || !Array.isArray(parsed.graphs)) {
635
+ throw new Error('missing the top-level "graphs" array');
636
+ }
637
+ } catch (e) {
638
+ throw new Error(`saveDashboardLayout: graphs is not a valid {"graphs":[...]} JSON string (${e.message}).`);
639
+ }
640
+ const data = await sdlGraphql(
641
+ 'SaveDashboardLayout',
642
+ `mutation SaveDashboardLayout($id: ID, $dashboardName: String, $graphs: String, $options: String, $tabName: String) {
643
+ saveDashboardLayout(id: $id, dashboardName: $dashboardName, graphs: $graphs, options: $options, tabName: $tabName) { graphs options }
644
+ }`,
645
+ { id: id ? assertSafeUdoId(id) : undefined, dashboardName: name, graphs, options, tabName },
646
+ { scope }
647
+ );
648
+ return data?.saveDashboardLayout ?? null;
649
+ }
650
+
651
+ /**
652
+ * Delete a dashboard by id or name. `deleteDashboard` returns a bare boolean,
653
+ * so per the house rule the removal is confirmed by re-reading rather than
654
+ * trusted from the mutation response.
655
+ */
656
+ export async function deleteDashboard({ id, name, scope }) {
657
+ if (!id && !name) throw new Error('deleteDashboard requires either id or name');
658
+ const data = await sdlGraphql(
659
+ 'DeleteDashboard',
660
+ 'mutation DeleteDashboard($id: ID, $dashboardName: String) { deleteDashboard(id: $id, dashboardName: $dashboardName) }',
661
+ { id: id ? assertSafeUdoId(id) : undefined, dashboardName: name },
662
+ { scope }
663
+ );
664
+ const reported = data?.deleteDashboard;
665
+
666
+ const still = await getDashboard({ id, name, scope });
667
+ if (still) {
668
+ throw new Error(
669
+ `deleteDashboard: ${id ? `id ${id}` : name} still exists after the delete mutation ` +
670
+ `(mutation returned ${JSON.stringify(reported)}). Nothing was removed.`
671
+ );
672
+ }
673
+ return { status: 'success', deleted: id ? { id: String(id) } : { name }, raw: reported ?? null };
674
+ }
675
+
360
676
  // ─── V1 Query (schema discovery) ─────────────────────────────────────────────
361
677
  // Deprecated Feb 15 2027 but still the only way to get full event JSON per-event.
362
678
  // Use for schema discovery; use LRQ for hunting.
363
679
 
364
680
  /** POST /api/query: retrieve raw event JSON for schema discovery.
365
681
  * Returns { matches: [{ timestamp, message, attributes }] }. */
366
- export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime } = {}) {
682
+ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime, scope } = {}) {
367
683
  const body = {
368
684
  queryType: 'log',
369
685
  filter,
@@ -373,5 +689,5 @@ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime
373
689
  if (endTime) body.endTime = endTime;
374
690
  // Read-only POST: opt back into status retry. Schema discovery iterates this
375
691
  // once per data source, which is the workload that trips the SDL QPS cap.
376
- return sdlFetch('POST', '/api/query', { body, allowRetry: true });
692
+ return sdlFetch('POST', '/api/query', { body, allowRetry: true, extraHeaders: scopeHeaders(scope) });
377
693
  }
@@ -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.3',
97
+ version: '1.3.5',
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.3",
3
+ "version": "1.3.5",
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",
@@ -44,6 +44,13 @@ const TOOL_SKILL = {
44
44
  sdl_get_file: 'sdl-api / sdl-dashboard / sdl-log-parser',
45
45
  sdl_put_file: 'sdl-api / sdl-dashboard / sdl-log-parser',
46
46
  sdl_delete_file: 'sdl-api',
47
+ // SDL dashboard lifecycle (dashboardsV2)
48
+ sdl_list_dashboards: 'sdl-api / sdl-dashboard',
49
+ sdl_get_dashboard: 'sdl-api / sdl-dashboard',
50
+ sdl_create_dashboard: 'sdl-api / sdl-dashboard',
51
+ sdl_share_dashboard: 'sdl-api / sdl-dashboard',
52
+ sdl_save_dashboard_layout: 'sdl-api / sdl-dashboard',
53
+ sdl_delete_dashboard: 'sdl-api / sdl-dashboard',
47
54
  hec_ingest: 'sdl-api / sdl-log-parser',
48
55
  // Hyperautomation
49
56
  ha_list_workflows: 'hyperautomation',
@@ -5,7 +5,7 @@
5
5
  # Exercises the public contract end-to-end:
6
6
  # 1. healthz returns 200 (no auth)
7
7
  # 2. initialize returns the expected protocol version and server info
8
- # 3. tools/list returns 26 tools
8
+ # 3. tools/list returns 32 tools
9
9
  # 4. tools/call s1_api_get works (uses /agents/count as a cheap probe)
10
10
  # 5. bad bearer returns HTTP 401
11
11
  # 6. unknown method returns JSON-RPC error -32601 inside a 200 envelope
@@ -81,7 +81,7 @@ echo "=== 3. tools/list count ==="
81
81
  TOOLS_COUNT=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
82
82
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' |
83
83
  jq '.result.tools | length')
84
- [[ "$TOOLS_COUNT" == "26" ]] && pass "tools/list returned 26 tools" || fail "tools/list returned $TOOLS_COUNT"
84
+ [[ "$TOOLS_COUNT" == "32" ]] && pass "tools/list returned 32 tools" || fail "tools/list returned $TOOLS_COUNT"
85
85
 
86
86
  echo
87
87
  echo "=== 4. tools/call s1_api_get on /agents/count ==="
@@ -128,8 +128,8 @@ STDIO_REPLY="$(printf '%s\n%s\n' \
128
128
  node index.js 2>/dev/null)"
129
129
 
130
130
  TOOL_COUNT="$(echo "$STDIO_REPLY" | tail -n 1 | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{console.log(JSON.parse(s).result.tools.length)}catch(e){console.log("ERR")}})')"
131
- if [[ "$TOOL_COUNT" == "26" ]]; then
132
- pass "stdio tools/list returned 26 tools"
131
+ if [[ "$TOOL_COUNT" == "32" ]]; then
132
+ pass "stdio tools/list returned 32 tools"
133
133
  else
134
134
  fail "stdio tools/list returned $TOOL_COUNT (expected 26)" "$STDIO_REPLY"
135
135
  fi
@@ -159,8 +159,8 @@ HTTP_REPLY="$(curl -sf -X POST "http://127.0.0.1:$PORT/mcp" \
159
159
  -H 'Content-Type: application/json' \
160
160
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}')"
161
161
  HTTP_COUNT="$(echo "$HTTP_REPLY" | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{console.log(JSON.parse(s).result.tools.length)}catch(e){console.log("ERR")}})')"
162
- if [[ "$HTTP_COUNT" == "26" ]]; then
163
- pass "HTTP tools/list returned 26 tools"
162
+ if [[ "$HTTP_COUNT" == "32" ]]; then
163
+ pass "HTTP tools/list returned 32 tools"
164
164
  else
165
165
  fail "HTTP tools/list returned $HTTP_COUNT" "$HTTP_REPLY"
166
166
  fi
@@ -23,15 +23,19 @@ export const tools = [
23
23
  description: 'Lookback window in hours (default 24). Increase to 168 (7d) if the last 24h had low volume.',
24
24
  default: 24,
25
25
  },
26
+ scope: {
27
+ type: 'string',
28
+ description: 'Optional S1-Scope, "<accountId>" or "<accountId>:<siteId>". LOG READS ARE SCOPE-FILTERED just like config reads, so this changes which events the query can see. Use it to hunt within one site, and to validate a site-scoped dashboard panel against the same boundary the dashboard will see. Omit to use S1_SCOPE from credentials.json, or the token default when that is unset.',
29
+ },
26
30
  },
27
31
  required: [],
28
32
  },
29
- async handler({ hours = 24 } = {}) {
33
+ async handler({ hours = 24, scope } = {}) {
30
34
  const query = `| group UniqueDataSourceNames = array_agg_distinct(dataSource.name),
31
35
  UniqueVendors = array_agg_distinct(dataSource.vendor),
32
36
  UniqueCategories = array_agg_distinct(dataSource.category)
33
37
  | limit 1000`;
34
- const result = await lrqRun(query, { hours });
38
+ const result = await lrqRun(query, { hours, scope });
35
39
  return JSON.stringify(result, null, 2);
36
40
  },
37
41
  },
@@ -65,11 +69,15 @@ export const tools = [
65
69
  description: 'Client-side cap on rows returned (default 1000). Not a hard backend limit: the LRQ engine returns as many rows as the query\'s own `| limit N` asks for (live-verified 2026-07-29: a `| limit 20000` query returned 20,000 rows in one response). Raise this to match a large `| limit`; the real ceiling is LRQ response size, not a fixed 5000.',
66
70
  default: 1000,
67
71
  },
72
+ scope: {
73
+ type: 'string',
74
+ description: 'Optional S1-Scope, "<accountId>" or "<accountId>:<siteId>". LOG READS ARE SCOPE-FILTERED just like config reads, so this changes which events the query can see. Use it to hunt within one site, and to validate a site-scoped dashboard panel against the same boundary the dashboard will see. Omit to use S1_SCOPE from credentials.json, or the token default when that is unset.',
75
+ },
68
76
  },
69
77
  required: ['query'],
70
78
  },
71
- async handler({ query, startTime, endTime, hours = 24, maxRows = 1000 }) {
72
- const result = await lrqRun(query, { startTime, endTime, hours, maxRows });
79
+ async handler({ query, startTime, endTime, hours = 24, maxRows = 1000, scope }) {
80
+ const result = await lrqRun(query, { startTime, endTime, hours, maxRows, scope });
73
81
  return JSON.stringify(result, null, 2);
74
82
  },
75
83
  },
@@ -95,17 +103,21 @@ export const tools = [
95
103
  description: 'Lookback string or ISO date, e.g. "24h", "7d", or "2026-04-20T00:00:00Z" (default "24h").',
96
104
  default: '24h',
97
105
  },
106
+ scope: {
107
+ type: 'string',
108
+ description: 'Optional S1-Scope, "<accountId>" or "<accountId>:<siteId>". Schema discovery is scope-filtered: a source present at one site may be absent at another, so discover at the scope you will query.',
109
+ },
98
110
  },
99
111
  required: ['dataSourceName'],
100
112
  },
101
- async handler({ dataSourceName, maxEvents = 5, startTime = '24h' }) {
113
+ async handler({ dataSourceName, maxEvents = 5, startTime = '24h', scope }) {
102
114
  // Escape backslashes first, then single quotes, to keep tenant-defined
103
115
  // source names from breaking (or altering) the V1 filter expression.
104
116
  // Quote-only escaping let a trailing backslash neutralise the added
105
117
  // escape (e.g. name\' -> \\' which re-opens the string).
106
118
  const safeName = String(dataSourceName).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
107
119
  const filter = `dataSource.name=='${safeName}'`;
108
- const result = await v1Query(filter, { maxCount: Math.min(maxEvents, 50), startTime });
120
+ const result = await v1Query(filter, { maxCount: Math.min(maxEvents, 50), startTime, scope });
109
121
 
110
122
  const matches = result.matches || [];
111
123
  if (matches.length === 0) {
package/tools/sdl-api.js CHANGED
@@ -1,20 +1,34 @@
1
1
  /**
2
2
  * SDL API tools: sdl-api, sdl-dashboard, sdl-log-parser skills
3
3
  *
4
- * Tools:
5
- * sdl_list_files List every config file on the SDL tenant (GraphQL configFiles)
6
- * sdl_get_file Get file content and version, by path or udoId
7
- * sdl_put_file Deploy or update a config file (with optimistic locking)
8
- * sdl_delete_file Delete a config file
9
- * hec_ingest Ingest raw logs/events into SDL via the HEC endpoint (replaces uploadLogs)
4
+ * Config-file tools (raw SDL config layer):
5
+ * sdl_list_files List every config file visible at a scope
6
+ * sdl_get_file Get file content and version, by path or udoId
7
+ * sdl_put_file Deploy or update a config file (optimistic locking)
8
+ * sdl_delete_file Delete a config file
10
9
  *
11
- * All four config-file tools run on `POST /sdl/v2/graphql`. The legacy REST
10
+ * Dashboard lifecycle tools (dashboardsV2, the console's own surface):
11
+ * sdl_list_dashboards List dashboards with owner and sharing metadata
12
+ * sdl_get_dashboard Read one dashboard including its tabs
13
+ * sdl_create_dashboard Create from a full dashboard-JSON config
14
+ * sdl_share_dashboard Share to a site / account / global scope
15
+ * sdl_save_dashboard_layout Replace the panel layout of one tab
16
+ * sdl_delete_dashboard Delete a dashboard
17
+ *
18
+ * Ingest:
19
+ * hec_ingest Ingest raw logs/events into SDL via HEC
20
+ *
21
+ * Everything except hec_ingest runs on `POST /sdl/v2/graphql`. The legacy REST
12
22
  * `/sdl/api/*File` endpoints are NOT used: they silently omit every
13
23
  * udoId-addressed dashboard (1,914 vs 2,264 files on a live tenant) and return
14
24
  * `success/noSuchFile` for any of them.
15
25
  */
16
26
 
17
- import { configFiles, configFile, putConfigFile, deleteConfigFile } from '../lib/sdl.js';
27
+ import {
28
+ configFiles, configFile, putConfigFile, deleteConfigFile,
29
+ listDashboards, getDashboard, createDashboard, shareDashboard,
30
+ saveDashboardLayout, deleteDashboard,
31
+ } from '../lib/sdl.js';
18
32
  import { hecIngest } from '../lib/hec.js';
19
33
 
20
34
  const UDOID_NOTE =
@@ -24,11 +38,25 @@ const UDOID_NOTE =
24
38
  'udoId assignment is by namespace: only /dashboards/ files have one, /lookups/, /datatables/, ' +
25
39
  '/logParsers/ and /automaticLookups are all name-addressed with udoId null.';
26
40
 
41
+ const SCOPE_NOTE =
42
+ 'SDL config objects are filed against the scope of the request and reads are FILTERED by it, so this ' +
43
+ 'argument changes which objects exist as far as the caller can tell. Verified live: the same listing ' +
44
+ 'returned 113 files at account scope and 4 at a site scope. Format "<accountId>" for account scope or ' +
45
+ '"<accountId>:<siteId>" for site scope; ids come from GET /web/api/v2.1/accounts and /sites. Omit to ' +
46
+ 'use S1_SCOPE from credentials.json, or the token default when that is unset. If an object you expect ' +
47
+ 'is missing, re-check at the scope it was created in before concluding it is gone.';
48
+
49
+ /** Shared scope property for every tool schema. */
50
+ const scopeProp = {
51
+ type: 'string',
52
+ description: `Optional S1-Scope, e.g. "2046190533732727925:2547662415802335157". ${SCOPE_NOTE}`,
53
+ };
54
+
27
55
  export const tools = [
28
56
  // ─── sdl_list_files ───────────────────────────────────────────────────────
29
57
  {
30
58
  name: 'sdl_list_files',
31
- description: `List every configuration file on the SDL tenant via the GraphQL configFiles query: /logParsers/, /dashboards/, /alerts/, /lookups/, /datatables/, /automaticLookups. Returns {udoId, name, readOnly, version} per file. ${UDOID_NOTE} Use this to discover what is deployed, and to resolve a dashboard name to the udoId that sdl_get_file/sdl_put_file need. Never conclude a file is absent from a listing produced any other way; the legacy REST listing omits ~350 dashboards.`,
59
+ description: `List every configuration file visible at the given scope via the GraphQL configFiles query: /logParsers/, /dashboards/, /alerts/, /lookups/, /datatables/, /automaticLookups. Returns {udoId, name, readOnly, version} per file. ${UDOID_NOTE} Use this to discover what is deployed, and to resolve a dashboard name to the udoId that sdl_get_file/sdl_put_file need. THIS LISTING IS SCOPE-FILTERED: ${SCOPE_NOTE} Never conclude a file is absent from a listing produced any other way; the legacy REST listing omits ~350 dashboards.`,
32
60
  inputSchema: {
33
61
  type: 'object',
34
62
  properties: {
@@ -36,13 +64,14 @@ export const tools = [
36
64
  type: 'string',
37
65
  description: 'Optional filter, e.g. "/dashboards/" or "/logParsers/". Applied client-side to the full listing.',
38
66
  },
67
+ scope: scopeProp,
39
68
  },
40
69
  required: [],
41
70
  },
42
- async handler({ pathPrefix } = {}) {
43
- let files = await configFiles();
71
+ async handler({ pathPrefix, scope } = {}) {
72
+ let files = await configFiles({ scope });
44
73
  if (pathPrefix) files = files.filter(f => (f.name || '').startsWith(pathPrefix));
45
- return JSON.stringify({ count: files.length, files }, null, 2);
74
+ return JSON.stringify({ count: files.length, scope: scope ?? null, files }, null, 2);
46
75
  },
47
76
  },
48
77
 
@@ -61,15 +90,17 @@ export const tools = [
61
90
  type: 'string',
62
91
  description: 'Dashboard udoId, e.g. "3559330396332032". Get it from sdl_list_files. Required for /dashboards/ files.',
63
92
  },
93
+ scope: scopeProp,
64
94
  },
65
95
  required: [],
66
96
  },
67
- async handler({ path, udoId }) {
68
- const result = await configFile({ name: path, udoId });
97
+ async handler({ path, udoId, scope }) {
98
+ const result = await configFile({ name: path, udoId, scope });
69
99
  if (!result) {
70
100
  return JSON.stringify({
71
101
  status: 'notFound',
72
- hint: 'No file at that address. If this is a dashboard, it is udoId-addressed: run sdl_list_files with pathPrefix "/dashboards/" and retry with its udoId.',
102
+ scope: scope ?? null,
103
+ hint: 'No file at that address AT THIS SCOPE. Two things to check: (1) if this is a dashboard it is udoId-addressed, run sdl_list_files with pathPrefix "/dashboards/" and retry with its udoId; (2) the file may exist at a different scope, a site-scoped dashboard is invisible from account scope, so retry with the scope it was created in.',
73
104
  }, null, 2);
74
105
  }
75
106
  return JSON.stringify(result, null, 2);
@@ -99,11 +130,12 @@ export const tools = [
99
130
  type: 'number',
100
131
  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
132
  },
133
+ scope: scopeProp,
102
134
  },
103
135
  required: ['content'],
104
136
  },
105
- async handler({ path, udoId, content, expectedVersion }) {
106
- const result = await putConfigFile({ name: path, udoId, content, expectedVersion });
137
+ async handler({ path, udoId, content, expectedVersion, scope }) {
138
+ const result = await putConfigFile({ name: path, udoId, content, expectedVersion, scope });
107
139
  return JSON.stringify(result, null, 2);
108
140
  },
109
141
  },
@@ -127,11 +159,147 @@ export const tools = [
127
159
  type: 'number',
128
160
  description: 'Current file version for optimistic locking (from sdl_get_file). Strongly recommended.',
129
161
  },
162
+ scope: scopeProp,
163
+ },
164
+ required: [],
165
+ },
166
+ async handler({ path, udoId, expectedVersion, scope }) {
167
+ const result = await deleteConfigFile({ name: path, udoId, expectedVersion, scope });
168
+ return JSON.stringify(result, null, 2);
169
+ },
170
+ },
171
+
172
+ // ─── sdl_list_dashboards ──────────────────────────────────────────────────
173
+ {
174
+ name: 'sdl_list_dashboards',
175
+ description: `List dashboards visible at the given scope via the GraphQL dashboardsV2 query, returning {id, name, description, configType, access:{public, users, owner}} each. Prefer this over sdl_list_files when you need the owner or the sharing state; use sdl_list_files when you need the config-file version for optimistic locking. The "id" here IS the "udoId" in sdl_list_files, they address the same object. ${SCOPE_NOTE}`,
176
+ inputSchema: {
177
+ type: 'object',
178
+ properties: { scope: scopeProp },
179
+ required: [],
180
+ },
181
+ async handler({ scope } = {}) {
182
+ const dashboards = await listDashboards({ scope });
183
+ return JSON.stringify({ count: dashboards.length, scope: scope ?? null, dashboards }, null, 2);
184
+ },
185
+ },
186
+
187
+ // ─── sdl_get_dashboard ────────────────────────────────────────────────────
188
+ {
189
+ name: 'sdl_get_dashboard',
190
+ description: `Read one dashboard including its tabs, description, duration, sharing and authorship, via getDashboardV2. Address by id (preferred) or name. NOTE tabs[].graphs, .parameters, .filters and .options come back as JSON STRINGS, not objects; parse each one to inspect panels. The "version" field here is a display string and is usually empty; it is NOT the optimistic-locking token, use sdl_get_file for the numeric version. Returns status notFound when the dashboard does not exist at this scope. ${SCOPE_NOTE}`,
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ id: { type: 'string', description: 'Dashboard id, e.g. "6994516145065984". Same value as udoId in sdl_list_files.' },
195
+ name: { type: 'string', description: 'Dashboard display name, e.g. "Metacortex Site". Use when you do not have the id; ambiguous if duplicates exist.' },
196
+ scope: scopeProp,
197
+ },
198
+ required: [],
199
+ },
200
+ async handler({ id, name, scope }) {
201
+ const result = await getDashboard({ id, name, scope });
202
+ if (!result) {
203
+ return JSON.stringify({
204
+ status: 'notFound',
205
+ scope: scope ?? null,
206
+ hint: 'No dashboard at that address AT THIS SCOPE. A site-scoped dashboard is invisible from account scope and vice versa; retry with the scope it was created in, or run sdl_list_dashboards at that scope to confirm.',
207
+ }, null, 2);
208
+ }
209
+ return JSON.stringify(result, null, 2);
210
+ },
211
+ },
212
+
213
+ // ─── sdl_create_dashboard ─────────────────────────────────────────────────
214
+ {
215
+ name: 'sdl_create_dashboard',
216
+ description: `Create a dashboard from a complete dashboard-JSON document via createDashboardV2, filed at the given scope. THIS IS THE PREFERRED WAY TO DEPLOY A NEW DASHBOARD: it accepts the whole document (configType, duration, description, tabs[]) in one call, unlike sdl_put_file which writes the raw config file. It also avoids the console's stub-append trap, where creating an empty dashboard in the UI and pasting JSON after the existing "{graphs: []}" stub yields "Content is invalid json / Additional text after JSON object" and leaves an empty dashboard behind. To deploy to a SITE, either pass scope as "<accountId>:<siteId>" here, or create at account scope and then use sdl_share_dashboard. Duplicate names ARE allowed (the console itself makes "<name> - Copy" siblings); set failIfNameExists to refuse instead. ${SCOPE_NOTE}`,
217
+ inputSchema: {
218
+ type: 'object',
219
+ properties: {
220
+ name: { type: 'string', description: 'Dashboard display name, e.g. "Metacortex Site Replica".' },
221
+ config: { type: 'string', description: 'The full dashboard JSON document as a string: {"configType":"TABBED","duration":"24h","description":"...","tabs":[...]}. Validated as JSON before the mutation is sent.' },
222
+ isPublic: { type: 'boolean', description: 'Share with all users in scope (the console\'s "Public" badge). Default false.' },
223
+ failIfNameExists: { type: 'boolean', description: 'Refuse if a dashboard of this name already exists at this scope. Default false, which permits siblings. Costs one extra listing call.' },
224
+ scope: scopeProp,
225
+ },
226
+ required: ['name', 'config'],
227
+ },
228
+ async handler({ name, config, isPublic, failIfNameExists, scope }) {
229
+ const result = await createDashboard({ name, config, isPublic, failIfNameExists, scope });
230
+ return JSON.stringify({ status: 'created', scope: scope ?? null, dashboard: result }, null, 2);
231
+ },
232
+ },
233
+
234
+ // ─── sdl_share_dashboard ──────────────────────────────────────────────────
235
+ {
236
+ name: 'sdl_share_dashboard',
237
+ description: 'Share (or unshare) a dashboard to one or more scopes and/or users via the shareResource mutation. THIS IS THE ONLY SDL OPERATION THAT TAKES AN EXPLICIT SCOPE TARGET; every other operation infers scope from the request header. Use it to push an account-scoped dashboard down to a specific site without recreating it, which is how site-level deployment is done when the calling token sits at account scope. Note the two different scope arguments: the "scopes" array is WHERE THE DASHBOARD GOES, while "scope" is the header for this call, i.e. where you are standing when you share.',
238
+ inputSchema: {
239
+ type: 'object',
240
+ properties: {
241
+ id: { type: 'string', description: 'Dashboard id from sdl_list_dashboards or sdl_create_dashboard.' },
242
+ scopes: {
243
+ type: 'array',
244
+ description: 'Share targets. Each entry is {scopeType, scopeId, operation}: scopeType is "site" | "account" | "global"; scopeId is the numeric id from GET /web/api/v2.1/sites or /accounts (not required for global); operation is "ADD" or "REMOVE" (default ADD). Example: [{"scopeType":"site","scopeId":"2547662415802335157","operation":"ADD"}].',
245
+ items: {
246
+ type: 'object',
247
+ properties: {
248
+ scopeType: { type: 'string', enum: ['site', 'account', 'global'] },
249
+ scopeId: { type: 'string' },
250
+ operation: { type: 'string', enum: ['ADD', 'REMOVE'] },
251
+ },
252
+ required: ['scopeType'],
253
+ },
254
+ },
255
+ users: { type: 'array', description: 'Optional user share targets, same command shape as the console sends. Pass [] when sharing only to scopes.', items: { type: 'object' } },
256
+ scope: scopeProp,
257
+ },
258
+ required: ['id'],
259
+ },
260
+ async handler({ id, scopes, users, scope }) {
261
+ const result = await shareDashboard({ id, scopes, users, scope });
262
+ return JSON.stringify(result, null, 2);
263
+ },
264
+ },
265
+
266
+ // ─── sdl_save_dashboard_layout ────────────────────────────────────────────
267
+ {
268
+ name: 'sdl_save_dashboard_layout',
269
+ description: 'Replace the panel layout of ONE tab of a dashboard via saveDashboardLayout. Use for incremental panel edits (repositioning, adding or removing a panel on a single tab); use sdl_create_dashboard for a whole new document, or sdl_put_file with expectedVersion to rewrite an existing dashboard\'s full config. The graphs argument is a JSON string shaped {"graphs":[...]} including the wrapper key, even though the response echoes a bare array.',
270
+ inputSchema: {
271
+ type: 'object',
272
+ properties: {
273
+ id: { type: 'string', description: 'Dashboard id.' },
274
+ name: { type: 'string', description: 'Dashboard display name, as an alternative to id.' },
275
+ tabName: { type: 'string', description: 'Exact tab name to replace, e.g. "2. Metacortex operations". Must match an existing tab.' },
276
+ graphs: { type: 'string', description: 'JSON string shaped {"graphs":[{panel},...]}. Validated before sending; the top-level "graphs" array is required.' },
277
+ options: { type: 'string', description: 'Optional tab options as a JSON string, e.g. "{}".' },
278
+ scope: scopeProp,
279
+ },
280
+ required: ['graphs', 'tabName'],
281
+ },
282
+ async handler({ id, name, tabName, graphs, options, scope }) {
283
+ const result = await saveDashboardLayout({ id, name, tabName, graphs, options, scope });
284
+ return JSON.stringify(result, null, 2);
285
+ },
286
+ },
287
+
288
+ // ─── sdl_delete_dashboard ─────────────────────────────────────────────────
289
+ {
290
+ name: 'sdl_delete_dashboard',
291
+ description: `Delete a dashboard by id or name via the deleteDashboard mutation. Deletion is permanent. The mutation returns a bare boolean, so this tool re-reads the dashboard afterwards and only reports success once it is confirmed gone. Equivalent to sdl_delete_file by udoId; prefer this one when you are working through the dashboard surface. ${SCOPE_NOTE}`,
292
+ inputSchema: {
293
+ type: 'object',
294
+ properties: {
295
+ id: { type: 'string', description: 'Dashboard id to delete.' },
296
+ name: { type: 'string', description: 'Dashboard name to delete, as an alternative to id. Ambiguous if duplicates exist; prefer id.' },
297
+ scope: scopeProp,
130
298
  },
131
299
  required: [],
132
300
  },
133
- async handler({ path, udoId, expectedVersion }) {
134
- const result = await deleteConfigFile({ name: path, udoId, expectedVersion });
301
+ async handler({ id, name, scope }) {
302
+ const result = await deleteDashboard({ id, name, scope });
135
303
  return JSON.stringify(result, null, 2);
136
304
  },
137
305
  },