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

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,95 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.4 - 2026-08-17
4
+
5
+ Adds site-level dashboard lifecycle. Two gaps closed: SDL GraphQL calls never
6
+ sent an `S1-Scope` header, and the dashboard operations the console itself uses
7
+ were not wrapped at all. **26 tools → 32.**
8
+
9
+ ### Added
10
+
11
+ - **`S1-Scope` on every SDL GraphQL and V1-query call.** Optional `scope`
12
+ argument on `configFiles`, `configFile`, `putConfigFile`, `deleteConfigFile`
13
+ and `v1Query`, and on the four `sdl_*_file` tools. Falls back to a new
14
+ `S1_SCOPE` credential; `scope: null` suppresses that default and sends no
15
+ header. Format `"<accountId>"` or `"<accountId>:<siteId>"`, validated before
16
+ the request so a typo cannot silently widen the read.
17
+ - **Six dashboard-lifecycle tools** on the `dashboardsV2` surface:
18
+ `sdl_list_dashboards`, `sdl_get_dashboard`, `sdl_create_dashboard`,
19
+ `sdl_share_dashboard`, `sdl_save_dashboard_layout`, `sdl_delete_dashboard`.
20
+ - **`sdl_create_dashboard`** takes the whole dashboard document as one `config`
21
+ string, the path the console uses. It parses the JSON first, so the UI
22
+ stub-append failure (`{graphs: []}{...}` → "Content is invalid json /
23
+ Additional text after JSON object", leaving an empty dashboard behind) is
24
+ reported as a caller error instead of filing a broken shell.
25
+ - **`sdl_share_dashboard`** wraps `shareResource`, the only SDL operation that
26
+ takes an explicit scope target. This is how an account-scoped dashboard is
27
+ pushed to a site without recreating it. Scope targets are validated up front,
28
+ because the server accepts a malformed entry, shares nothing, and reports
29
+ success.
30
+ - **`S1_SCOPE`** added to `getCreds()`. It was absent, so any credentials-file
31
+ default would have been read and then dropped.
32
+
33
+ ### Fixed
34
+
35
+ - **Scope-sensitive call sites now scope consistently.** Absence
36
+ disambiguation re-lists at the scope of the failed lookup, the `/dashboards/`
37
+ duplicate guard lists at the scope of the write, and delete verification
38
+ re-reads at the scope of the delete. Mixing scopes across these steps reports
39
+ a live site-scoped file as deleted, which is the same false-negative class
40
+ 1.3.3 fixed for error text.
41
+
42
+ ### Corrected documentation
43
+
44
+ - `sdl-api/references/config-file-graphql.md` claimed the `s1-scope` header was
45
+ "ignored, not rejected" on `/sdl/v2/graphql`. **That was wrong.** Measured on
46
+ `usea1-purple`, same token and query: `configFiles` returned 113 files at
47
+ account scope and 4 at a site scope. Config listings and dashboard reads are
48
+ scope-FILTERED, so a dropped header changes which objects appear to exist.
49
+ `auth_and_limits.md` corrected to match.
50
+
51
+ ### Dashboard skill
52
+
53
+ - **`site.id`, not `site.name`, is the scoping predicate.** For one site over
54
+ 24h, `site.id='<id>'` matched 60,410 events of which 510 carried the site id
55
+ with a null `site.name`: `ActivityFeed` 172, `asset` 111, unattributed 99,
56
+ `SentinelOne` 70, `Windows Event Logs` 48, `alert` 10. A `site.name` filter
57
+ silently drops alert and asset records. `site.id` is also the same value as
58
+ the `S1-Scope` `siteId` and survives a site rename.
59
+ - **New scope doctrine in `sdl-dashboard/SKILL.md`:** deployment scope and query
60
+ scope are separate decisions, and a site-deployed dashboard scopes its panels
61
+ to that site unless the user explicitly asks for account-wide queries.
62
+ - **`panel_safety_check.py --site-id <id>`** adds rule **S01** (query panel with
63
+ no, or wrong, `site.id` predicate on a site-targeted dashboard; opt out with
64
+ `--allow-account-scope-queries`) and rule **S02** (`site.name` used as a
65
+ scoping filter; never suppressed, the substitution is wrong at any scope).
66
+ - Recorded that the console's XDR selector injects
67
+ `preFilter: "dataSource.category = 'security'"` into every panel query.
68
+
69
+ ### Python client (`sdl-api/scripts/sdl_client.py`)
70
+
71
+ - Per-call `scope` on `config_files`, `config_file`, `put_config_file`,
72
+ `delete_config_file`, with the same `_UNSET`-vs-`None` distinction.
73
+ - Six new methods mirroring the JS layer: `list_dashboards`, `get_dashboard`,
74
+ `create_dashboard`, `share_dashboard`, `save_dashboard_layout`,
75
+ `delete_dashboard`.
76
+ - `get_dashboard` treats both a null result and a GraphQL error as absence,
77
+ disambiguated against the listing. Assuming only the null form is what broke
78
+ every delete in 1.3.2; the confirming re-read threw on the success path.
79
+
80
+ ### Tests
81
+
82
+ - 24 new cases in `tests/sdl-graphql.test.mjs`: header presence and absence,
83
+ credentials fallback, `scope: null` suppression, malformed-scope rejection
84
+ before any request, scope consistency across the guard / disambiguation /
85
+ delete-verify paths, the six dashboard operations, stub-append rejection, and
86
+ the `getDashboard` absence matrix.
87
+ - New `sdl-dashboard/tests/test_panel_safety_check.py`: 19 cases over S01 and
88
+ S02 including the wrong-site case, the opt-out flag, exempt panel types, and
89
+ a regression guard that existing rules still fire.
90
+ - Tool-count assertions updated 26 → 32 across the smoke, stdio, HTTP and
91
+ origin-guard suites.
92
+
3
93
  ## 1.3.3 - 2026-08-07
4
94
 
5
95
  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.4"],
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.4"]
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.4` + `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/sdl.js CHANGED
@@ -26,6 +26,48 @@ 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
+ function scopeHeaders(scope) {
67
+ const resolved = resolveScope(scope);
68
+ return resolved ? { 'S1-Scope': resolved } : {};
69
+ }
70
+
29
71
  function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
30
72
 
31
73
  function retryAfterMs(res, fallback) {
@@ -160,14 +202,15 @@ class SdlGraphqlError extends Error {
160
202
  * 3. Return a payload carrying neither `data` nor `errors`.
161
203
  *
162
204
  * `readOnly` opts into status-based retry; only pass it for queries.
205
+ * `scope` sets the S1-Scope header; see resolveScope for precedence.
163
206
  */
164
- async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) {
207
+ async function sdlGraphql(opname, query, variables, { readOnly = false, scope } = {}) {
165
208
  const body = { query };
166
209
  if (variables) body.variables = variables;
167
210
  const payload = await sdlFetch(
168
211
  'POST',
169
212
  `/v2/graphql?opname=${encodeURIComponent(opname)}`,
170
- { body, allowRetry: readOnly }
213
+ { body, allowRetry: readOnly, extraHeaders: scopeHeaders(scope) }
171
214
  );
172
215
 
173
216
  if (typeof payload !== 'object' || payload === null) {
@@ -211,13 +254,15 @@ function matchesName(file, name) {
211
254
  return normaliseName(file?.name) === normaliseName(name);
212
255
  }
213
256
 
214
- /** Every config file on the tenant, including udoId-addressed dashboards. */
215
- export async function configFiles() {
257
+ /** Every config file visible at `scope`, including udoId-addressed dashboards.
258
+ * This listing IS scope-filtered: a site-scoped dashboard does not appear in an
259
+ * account-scoped listing and vice versa. */
260
+ export async function configFiles({ scope } = {}) {
216
261
  const data = await sdlGraphql(
217
262
  'getConfigurationFiles',
218
263
  `query getConfigurationFiles { configFiles { ${CONFIG_FIELDS} } }`,
219
264
  undefined,
220
- { readOnly: true }
265
+ { readOnly: true, scope }
221
266
  );
222
267
  return data?.configFiles ?? [];
223
268
  }
@@ -239,15 +284,15 @@ export async function configFiles() {
239
284
  * file is genuinely gone, otherwise the error was real and is rethrown. The
240
285
  * extra listing only happens on the error path.
241
286
  */
242
- export async function configFile({ name, udoId }) {
287
+ export async function configFile({ name, udoId, scope }) {
243
288
  if (!name && !udoId) throw new Error('configFile requires either name or udoId');
244
289
  // Validate before the try: an invalid udoId is a caller bug, not a signal
245
290
  // that the file is absent, and must never be swallowed by the absence path.
246
291
  const safeUdoId = udoId ? assertSafeUdoId(udoId) : null;
247
292
  try {
248
293
  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 });
294
+ ? await sdlGraphql('configFile', `query f($udoId: ID!) { configFile(udoId: $udoId) { ${CONFIG_FIELDS} content } }`, { udoId: safeUdoId }, { readOnly: true, scope })
295
+ : await sdlGraphql('configFile', `query f($id: ID!) { configFile(id: $id) { ${CONFIG_FIELDS} content } }`, { id: name }, { readOnly: true, scope });
251
296
  return data?.configFile ?? null;
252
297
  } catch (err) {
253
298
  // Only a GraphQL-layer error can mean "absent". A transport failure whose
@@ -261,9 +306,13 @@ export async function configFile({ name, udoId }) {
261
306
  // returns, so settle it against the listing. If the listing itself fails,
262
307
  // surface the ORIGINAL error with the listing failure attached rather than
263
308
  // replacing it.
309
+ // Same scope as the failed lookup. Disambiguating against a DIFFERENT
310
+ // scope's listing would report a site-scoped file as absent purely because
311
+ // the listing was taken at account scope, which is the exact false-negative
312
+ // class this branch exists to remove.
264
313
  let all;
265
314
  try {
266
- all = await configFiles();
315
+ all = await configFiles({ scope });
267
316
  } catch (listErr) {
268
317
  err.message += ` (absence check failed: ${listErr.message})`;
269
318
  throw err;
@@ -282,13 +331,16 @@ export async function configFile({ name, udoId }) {
282
331
  * - name given → updates in place for plain files, but CREATES A DUPLICATE
283
332
  * for /dashboards/. Never write a dashboard by name.
284
333
  */
285
- export async function putConfigFile({ name, udoId, content, expectedVersion }) {
334
+ export async function putConfigFile({ name, udoId, content, expectedVersion, scope }) {
286
335
  if (!name && !udoId) throw new Error('putConfigFile requires either name or udoId');
287
336
  // Creating a dashboard must go by name (no udoId exists yet); only an
288
337
  // *existing* dashboard is at risk of being duplicated by a name-addressed
289
338
  // write. So refuse only when a file of that name already exists.
290
339
  if (!udoId && normaliseName(name).startsWith('/dashboards/')) {
291
- const all = await configFiles();
340
+ // Scoped to match the write. An account-scoped listing would not see a
341
+ // same-named site-scoped dashboard, so the guard has to look where the
342
+ // write is going, not where the token defaults.
343
+ const all = await configFiles({ scope });
292
344
  // An empty listing means the check could not run, not that the name is
293
345
  // free. Failing open here would silently disable the guard.
294
346
  if (!all.length) {
@@ -315,10 +367,10 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
315
367
  const data = udoId
316
368
  ? await sdlGraphql('addConfigFile',
317
369
  `mutation f($udoId: ID, $content: String!, $expectedVersion: Long) { addConfigFile(udoId: $udoId, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
318
- { udoId: assertSafeUdoId(udoId), content, expectedVersion })
370
+ { udoId: assertSafeUdoId(udoId), content, expectedVersion }, { scope })
319
371
  : await sdlGraphql('addConfigFile',
320
372
  `mutation f($name: String, $content: String!, $expectedVersion: Long) { addConfigFile(name: $name, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
321
- { name, content, expectedVersion });
373
+ { name, content, expectedVersion }, { scope });
322
374
  return data?.addConfigFile ?? null;
323
375
  }
324
376
 
@@ -327,21 +379,21 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
327
379
  * A null return with no errors array is SUCCESS; the deleted object is not
328
380
  * echoed back. Treating that null as a failure is the classic mistake here.
329
381
  */
330
- export async function deleteConfigFile({ name, udoId, expectedVersion }) {
382
+ export async function deleteConfigFile({ name, udoId, expectedVersion, scope }) {
331
383
  if (!name && !udoId) throw new Error('deleteConfigFile requires either name or udoId');
332
384
  const raw = udoId
333
385
  ? await sdlGraphql('deleteConfigFile',
334
386
  'mutation f($udoId: ID, $expectedVersion: Long) { deleteConfigFile(udoId: $udoId, expectedVersion: $expectedVersion) { udoId } }',
335
- { udoId: assertSafeUdoId(udoId), expectedVersion })
387
+ { udoId: assertSafeUdoId(udoId), expectedVersion }, { scope })
336
388
  : await sdlGraphql('deleteConfigFile',
337
389
  'mutation f($id: ID, $expectedVersion: Long) { deleteConfigFile(id: $id, expectedVersion: $expectedVersion) { udoId } }',
338
- { id: name, expectedVersion });
390
+ { id: name, expectedVersion }, { scope });
339
391
 
340
392
  // The mutation returns null on success and does not echo the deleted object,
341
393
  // so its response cannot distinguish "deleted" from "matched nothing". Confirm
342
394
  // by re-reading. This is the house rule established by uamSetStatus in
343
395
  // lib/s1.js: never treat a mutation response as proof, re-get and verify.
344
- const still = await configFile({ name, udoId });
396
+ const still = await configFile({ name, udoId, scope });
345
397
  if (still) {
346
398
  throw new Error(
347
399
  `deleteConfigFile: ${udoId ? `udoId ${udoId}` : name} still exists after the delete mutation ` +
@@ -357,13 +409,275 @@ export async function deleteConfigFile({ name, udoId, expectedVersion }) {
357
409
  // exist" decision. The GraphQL operations above cover every namespace,
358
410
  // including parsers, lookups, datatables and /automaticLookups.
359
411
 
412
+ // ─── Dashboard lifecycle (dashboardsV2, GraphQL) ──────────────────────────────
413
+ //
414
+ // A SECOND, HIGHER-LEVEL SURFACE on the same `POST /sdl/v2/graphql` endpoint.
415
+ // This is what the console itself drives; captured from live console traffic on
416
+ // usea1-purple 2026-08-17 (280 requests, 23 operations).
417
+ //
418
+ // Relationship to the config-file layer above:
419
+ //
420
+ // dashboardsV2 dashboard-aware: name, description, tabs, access/sharing,
421
+ // createdBy/updatedBy, isBuiltIn/isEditable. Create takes
422
+ // the whole dashboard JSON as one `config` string.
423
+ // configFiles the raw file underneath, addressed by udoId. Same object,
424
+ // no sharing or authorship metadata, `content` is the JSON.
425
+ //
426
+ // The `id` in dashboardsV2 IS the `udoId` in configFiles. Verified: dashboard
427
+ // "meta1" is id 6999000578736128 in getDashboardV2 and udoId 6999000578736128
428
+ // / name "/dashboards/meta1" in configFile.
429
+ //
430
+ // WHY THIS EXISTS: creating a dashboard through addConfigFile(name:) files it at
431
+ // the request's scope but gives no way to share it elsewhere, and the console's
432
+ // own create path is createDashboardV2. Site-level lifecycle needs both this and
433
+ // shareResource, which is the ONLY operation that takes an explicit scope target
434
+ // rather than inferring one from the request header.
435
+ //
436
+ // VERSION FIELDS DIFFER, do not cross them. getDashboardV2 returns
437
+ // `version: ""` (a display string, empty in practice); configFile returns
438
+ // `version: 215771284` (the numeric CAS token). Only the configFile value is
439
+ // valid as expectedVersion.
440
+
441
+ const DASHBOARD_SUMMARY_FIELDS = 'id name description configType access { public users owner }';
442
+
443
+ /** Every dashboard visible at `scope`, with sharing metadata. Prefer this over
444
+ * configFiles({pathPrefix:'/dashboards/'}) when you need owner or access. */
445
+ export async function listDashboards({ scope } = {}) {
446
+ const data = await sdlGraphql(
447
+ 'GetDashboardNames',
448
+ `query GetDashboardNames { dashboardsV2 { ${DASHBOARD_SUMMARY_FIELDS} } }`,
449
+ undefined,
450
+ { readOnly: true, scope }
451
+ );
452
+ return data?.dashboardsV2 ?? [];
453
+ }
454
+
455
+ /**
456
+ * Read one dashboard by id (preferred) or name, including its tabs.
457
+ * Returns null when it does not exist at `scope`.
458
+ *
459
+ * `tabs[].graphs` / `.parameters` / `.filters` / `.options` come back as JSON
460
+ * STRINGS, not objects; the console parses them client-side. Callers that want
461
+ * structure must JSON.parse each one.
462
+ *
463
+ * ABSENCE HANDLING mirrors configFile deliberately. The live capture only shows
464
+ * a successful read, so it is not established whether a missing dashboard comes
465
+ * back as `data.getDashboardV2 = null` or as a GraphQL error. Both are treated
466
+ * as absence, disambiguated against the dashboard listing. Assuming only the
467
+ * null form is what broke every `sdl_delete_file` in 1.3.2: the confirming
468
+ * re-read threw on precisely the success path. A transport-layer error is still
469
+ * rethrown, so a proxy page containing "not found" can never be read as absence.
470
+ */
471
+ export async function getDashboard({ id, name, scope }) {
472
+ if (!id && !name) throw new Error('getDashboard requires either id or name');
473
+ const safeId = id ? assertSafeUdoId(id) : undefined;
474
+ try {
475
+ const data = await sdlGraphql(
476
+ 'GetDashboardConfigV2',
477
+ `query GetDashboardConfigV2($id: ID, $dashboardName: String) {
478
+ getDashboardV2(id: $id, dashboardName: $dashboardName, resolveParameters: true) {
479
+ id name description configType duration isBuiltIn isEditable version
480
+ access { public users owner }
481
+ tabs { tabName parameters graphs filters options }
482
+ createdAt createdBy updatedAt updatedBy
483
+ }
484
+ }`,
485
+ { id: safeId, dashboardName: name },
486
+ { readOnly: true, scope }
487
+ );
488
+ return data?.getDashboardV2 ?? null;
489
+ } catch (err) {
490
+ if (!err.graphql) throw err;
491
+ let all;
492
+ try {
493
+ all = await listDashboards({ scope });
494
+ } catch (listErr) {
495
+ err.message += ` (absence check failed: ${listErr.message})`;
496
+ throw err;
497
+ }
498
+ const present = safeId
499
+ ? all.some(d => String(d.id) === safeId)
500
+ : all.some(d => normaliseName(d.name) === normaliseName(name));
501
+ if (!present) return null;
502
+ throw err;
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Create a dashboard from a full dashboard-JSON config, at `scope`.
508
+ *
509
+ * This is the console's own create path and it accepts the complete dashboard
510
+ * document (configType, duration, description, tabs[]) as ONE string. That is
511
+ * the important difference from the UI's "new dashboard then paste JSON" flow,
512
+ * which starts from a `{graphs: []}` stub: pasting after the stub instead of
513
+ * replacing it produces `{graphs: []}{...}` and the server rejects it with
514
+ * "Content is invalid json" / "Additional text after JSON object". Going
515
+ * through this function cannot hit that class of error.
516
+ *
517
+ * DUPLICATE NAMES ARE ALLOWED HERE, unlike putConfigFile. The console itself
518
+ * creates "<name> - Copy" siblings, and shareResource addresses dashboards by
519
+ * id, so duplicate names are not the footgun they are for name-addressed
520
+ * config-file writes. Set `failIfNameExists` to opt into the stricter
521
+ * behaviour; it costs one extra listing call.
522
+ */
523
+ export async function createDashboard({ name, config, isPublic = false, scope, failIfNameExists = false }) {
524
+ if (!name || typeof name !== 'string') throw new Error('createDashboard requires a name');
525
+ if (typeof config !== 'string' || !config.trim()) {
526
+ throw new Error('createDashboard requires config as a JSON string (the full dashboard document).');
527
+ }
528
+ // Fail before the mutation rather than filing a broken dashboard the console
529
+ // then renders as an empty shell.
530
+ try {
531
+ JSON.parse(config);
532
+ } catch (e) {
533
+ throw new Error(
534
+ `createDashboard: config is not valid JSON (${e.message}). ` +
535
+ 'If this came from the console\'s JSON editor, check for a leading "{graphs: []}" stub: ' +
536
+ 'the new document must REPLACE it, not follow it.'
537
+ );
538
+ }
539
+
540
+ if (failIfNameExists) {
541
+ const existing = (await listDashboards({ scope })).filter(d => normaliseName(d.name) === normaliseName(name));
542
+ if (existing.length) {
543
+ throw new Error(
544
+ `createDashboard: ${existing.length} dashboard(s) named "${name}" already exist at this scope ` +
545
+ `(ids: ${existing.map(d => d.id).join(', ')}). Pass failIfNameExists:false to create a sibling anyway.`
546
+ );
547
+ }
548
+ }
549
+
550
+ const data = await sdlGraphql(
551
+ 'CreateDashboard',
552
+ `mutation CreateDashboard($dashboardName: String!, $config: String, $public: Boolean) {
553
+ createDashboardV2(dashboardName: $dashboardName, config: $config, public: $public) { id name }
554
+ }`,
555
+ { dashboardName: name, config, public: isPublic },
556
+ { scope }
557
+ );
558
+ const created = data?.createDashboardV2 ?? null;
559
+ if (!created?.id) {
560
+ throw new SdlGraphqlError('createDashboard: mutation returned no id, so the dashboard was not created.');
561
+ }
562
+ return created;
563
+ }
564
+
565
+ /**
566
+ * Share a dashboard to scopes and/or users. THE ONLY OPERATION THAT TAKES AN
567
+ * EXPLICIT SCOPE TARGET; everything else infers scope from the S1-Scope header.
568
+ *
569
+ * `scopes` entries are {scopeType, scopeId, operation}:
570
+ * scopeType 'site' | 'account' | 'global'
571
+ * scopeId the numeric id from /web/api/v2.1/sites or /accounts
572
+ * operation 'ADD' | 'REMOVE'
573
+ *
574
+ * `scope` (the option, not the array) is still the header for the CALL, i.e.
575
+ * where you are standing when you share. It is independent of the targets.
576
+ */
577
+ const VALID_SCOPE_TYPES = new Set(['site', 'account', 'global']);
578
+ const VALID_SCOPE_OPS = new Set(['ADD', 'REMOVE']);
579
+
580
+ export async function shareDashboard({ id, scopes = [], users = [], scope }) {
581
+ if (!id) throw new Error('shareDashboard requires the dashboard id');
582
+ if (!Array.isArray(scopes) || !Array.isArray(users)) {
583
+ throw new Error('shareDashboard: scopes and users must both be arrays.');
584
+ }
585
+ if (!scopes.length && !users.length) {
586
+ throw new Error('shareDashboard: pass at least one scope or user, otherwise the call is a no-op.');
587
+ }
588
+ // Validate up front: the server accepts a malformed entry and silently shares
589
+ // nothing, which reads as success.
590
+ const normalisedScopes = scopes.map((s, i) => {
591
+ const type = String(s?.scopeType ?? '').toLowerCase();
592
+ const op = String(s?.operation ?? 'ADD').toUpperCase();
593
+ if (!VALID_SCOPE_TYPES.has(type)) {
594
+ throw new Error(`shareDashboard: scopes[${i}].scopeType must be one of ${[...VALID_SCOPE_TYPES].join(', ')} (got ${JSON.stringify(s?.scopeType)}).`);
595
+ }
596
+ if (!VALID_SCOPE_OPS.has(op)) {
597
+ throw new Error(`shareDashboard: scopes[${i}].operation must be ADD or REMOVE (got ${JSON.stringify(s?.operation)}).`);
598
+ }
599
+ if (type !== 'global' && !/^\d+$/.test(String(s?.scopeId ?? ''))) {
600
+ throw new Error(`shareDashboard: scopes[${i}].scopeId must be a numeric id for scopeType "${type}" (got ${JSON.stringify(s?.scopeId)}).`);
601
+ }
602
+ return { scopeType: type, scopeId: String(s.scopeId), operation: op };
603
+ });
604
+
605
+ const data = await sdlGraphql(
606
+ 'ShareDashboard',
607
+ `mutation ShareDashboard($id: ID!, $users: [UserSharingCommand], $scopes: [ScopeSharingCommand]) {
608
+ shareResource(id: $id, users: $users, scopes: $scopes) { id name }
609
+ }`,
610
+ { id: assertSafeUdoId(id), users, scopes: normalisedScopes },
611
+ { scope }
612
+ );
613
+ const shared = data?.shareResource ?? null;
614
+ if (!shared?.id) {
615
+ throw new SdlGraphqlError('shareDashboard: shareResource returned no id, so nothing was shared.');
616
+ }
617
+ return { status: 'success', dashboard: { id: String(shared.id), name: shared.name }, scopes: normalisedScopes, users };
618
+ }
619
+
620
+ /**
621
+ * Replace the panel layout of ONE tab. `graphs` is a JSON string shaped
622
+ * `{"graphs":[...]}` (note the wrapper key; the response echoes a bare array).
623
+ * Use this for incremental panel edits; use createDashboard for a whole document.
624
+ */
625
+ export async function saveDashboardLayout({ id, name, tabName, graphs, options, scope }) {
626
+ if (!id && !name) throw new Error('saveDashboardLayout requires either id or name');
627
+ if (typeof graphs !== 'string' || !graphs.trim()) {
628
+ throw new Error('saveDashboardLayout requires graphs as a JSON string, shaped {"graphs":[...]}.');
629
+ }
630
+ try {
631
+ const parsed = JSON.parse(graphs);
632
+ if (!parsed || !Array.isArray(parsed.graphs)) {
633
+ throw new Error('missing the top-level "graphs" array');
634
+ }
635
+ } catch (e) {
636
+ throw new Error(`saveDashboardLayout: graphs is not a valid {"graphs":[...]} JSON string (${e.message}).`);
637
+ }
638
+ const data = await sdlGraphql(
639
+ 'SaveDashboardLayout',
640
+ `mutation SaveDashboardLayout($id: ID, $dashboardName: String, $graphs: String, $options: String, $tabName: String) {
641
+ saveDashboardLayout(id: $id, dashboardName: $dashboardName, graphs: $graphs, options: $options, tabName: $tabName) { graphs options }
642
+ }`,
643
+ { id: id ? assertSafeUdoId(id) : undefined, dashboardName: name, graphs, options, tabName },
644
+ { scope }
645
+ );
646
+ return data?.saveDashboardLayout ?? null;
647
+ }
648
+
649
+ /**
650
+ * Delete a dashboard by id or name. `deleteDashboard` returns a bare boolean,
651
+ * so per the house rule the removal is confirmed by re-reading rather than
652
+ * trusted from the mutation response.
653
+ */
654
+ export async function deleteDashboard({ id, name, scope }) {
655
+ if (!id && !name) throw new Error('deleteDashboard requires either id or name');
656
+ const data = await sdlGraphql(
657
+ 'DeleteDashboard',
658
+ 'mutation DeleteDashboard($id: ID, $dashboardName: String) { deleteDashboard(id: $id, dashboardName: $dashboardName) }',
659
+ { id: id ? assertSafeUdoId(id) : undefined, dashboardName: name },
660
+ { scope }
661
+ );
662
+ const reported = data?.deleteDashboard;
663
+
664
+ const still = await getDashboard({ id, name, scope });
665
+ if (still) {
666
+ throw new Error(
667
+ `deleteDashboard: ${id ? `id ${id}` : name} still exists after the delete mutation ` +
668
+ `(mutation returned ${JSON.stringify(reported)}). Nothing was removed.`
669
+ );
670
+ }
671
+ return { status: 'success', deleted: id ? { id: String(id) } : { name }, raw: reported ?? null };
672
+ }
673
+
360
674
  // ─── V1 Query (schema discovery) ─────────────────────────────────────────────
361
675
  // Deprecated Feb 15 2027 but still the only way to get full event JSON per-event.
362
676
  // Use for schema discovery; use LRQ for hunting.
363
677
 
364
678
  /** POST /api/query: retrieve raw event JSON for schema discovery.
365
679
  * Returns { matches: [{ timestamp, message, attributes }] }. */
366
- export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime } = {}) {
680
+ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime, scope } = {}) {
367
681
  const body = {
368
682
  queryType: 'log',
369
683
  filter,
@@ -373,5 +687,5 @@ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime
373
687
  if (endTime) body.endTime = endTime;
374
688
  // Read-only POST: opt back into status retry. Schema discovery iterates this
375
689
  // once per data source, which is the workload that trips the SDL QPS cap.
376
- return sdlFetch('POST', '/api/query', { body, allowRetry: true });
690
+ return sdlFetch('POST', '/api/query', { body, allowRetry: true, extraHeaders: scopeHeaders(scope) });
377
691
  }
@@ -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.4',
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.4",
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
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
  },