@pmoses-s1/s1-secops-mcp 1.3.2 → 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/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) {
@@ -133,6 +175,17 @@ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null,
133
175
  // dashboard by udoId. Skipping that rule is how one tenant accumulated 152
134
176
  // copies of `/dashboards/AI Usage`.
135
177
 
178
+ /** Marks an error as originating from the GraphQL layer rather than the
179
+ * transport. Absence detection keys off this: a 404 page whose body contains
180
+ * "not found" must never be read as "the file does not exist". */
181
+ class SdlGraphqlError extends Error {
182
+ constructor(message) {
183
+ super(message);
184
+ this.name = 'SdlGraphqlError';
185
+ this.graphql = true;
186
+ }
187
+ }
188
+
136
189
  /**
137
190
  * POST /sdl/v2/graphql. Returns `data`; throws on the GraphQL `errors` array.
138
191
  *
@@ -149,14 +202,15 @@ async function sdlFetch(method, path, { body, extraHeaders = {}, rawBody = null,
149
202
  * 3. Return a payload carrying neither `data` nor `errors`.
150
203
  *
151
204
  * `readOnly` opts into status-based retry; only pass it for queries.
205
+ * `scope` sets the S1-Scope header; see resolveScope for precedence.
152
206
  */
153
- async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) {
207
+ async function sdlGraphql(opname, query, variables, { readOnly = false, scope } = {}) {
154
208
  const body = { query };
155
209
  if (variables) body.variables = variables;
156
210
  const payload = await sdlFetch(
157
211
  'POST',
158
212
  `/v2/graphql?opname=${encodeURIComponent(opname)}`,
159
- { body, allowRetry: readOnly }
213
+ { body, allowRetry: readOnly, extraHeaders: scopeHeaders(scope) }
160
214
  );
161
215
 
162
216
  if (typeof payload !== 'object' || payload === null) {
@@ -170,10 +224,10 @@ async function sdlGraphql(opname, query, variables, { readOnly = false } = {}) {
170
224
  const errs = Array.isArray(payload.errors) ? payload.errors : [payload.errors];
171
225
  const correlationId = payload.extensions?.correlationId ?? errs[0]?.extensions?.correlationId;
172
226
  const msg = errs[0]?.message || 'unknown GraphQL error';
173
- throw new Error(`SDL GraphQL ${opname} → ${msg}${correlationId ? ` (correlationId=${correlationId})` : ''}`);
227
+ throw new SdlGraphqlError(`SDL GraphQL ${opname} → ${msg}${correlationId ? ` (correlationId=${correlationId})` : ''}`);
174
228
  }
175
229
  if (!('data' in payload)) {
176
- throw new Error(`SDL GraphQL ${opname}: response carried neither data nor errors.`);
230
+ throw new SdlGraphqlError(`SDL GraphQL ${opname}: response carried neither data nor errors.`);
177
231
  }
178
232
  return payload.data;
179
233
  }
@@ -191,24 +245,84 @@ function assertSafeUdoId(udoId) {
191
245
 
192
246
  const CONFIG_FIELDS = 'udoId name readOnly version';
193
247
 
194
- /** Every config file on the tenant, including udoId-addressed dashboards. */
195
- export async function configFiles() {
248
+ /** SDL config names are case-insensitive and tolerate stray whitespace, so the
249
+ * absence check and the duplicate guard must normalise identically. */
250
+ function normaliseName(n) {
251
+ return String(n ?? '').trim().toLowerCase();
252
+ }
253
+ function matchesName(file, name) {
254
+ return normaliseName(file?.name) === normaliseName(name);
255
+ }
256
+
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 } = {}) {
196
261
  const data = await sdlGraphql(
197
262
  'getConfigurationFiles',
198
263
  `query getConfigurationFiles { configFiles { ${CONFIG_FIELDS} } }`,
199
264
  undefined,
200
- { readOnly: true }
265
+ { readOnly: true, scope }
201
266
  );
202
267
  return data?.configFiles ?? [];
203
268
  }
204
269
 
205
- /** Read one config file by name (plain files) or udoId (dashboards). */
206
- export async function configFile({ name, udoId }) {
270
+ /**
271
+ * Read one config file by name (plain files) or udoId (dashboards).
272
+ * Returns null when the file does not exist.
273
+ *
274
+ * Absence is a normal outcome of a lookup, but the server reports it as a
275
+ * GraphQL error, and the message differs by address form (verified live):
276
+ *
277
+ * by name : "Config file with name /x/y not found." <- explicit
278
+ * by udoId: "Something went wrong. Please try again..." <- generic, and the
279
+ * SAME text a version conflict returns, so it cannot be trusted
280
+ * on message alone.
281
+ *
282
+ * So the explicit form is normalised directly, and the ambiguous one is
283
+ * disambiguated against the file listing: absent from configFiles means the
284
+ * file is genuinely gone, otherwise the error was real and is rethrown. The
285
+ * extra listing only happens on the error path.
286
+ */
287
+ export async function configFile({ name, udoId, scope }) {
207
288
  if (!name && !udoId) throw new Error('configFile requires either name or udoId');
208
- const data = udoId
209
- ? await sdlGraphql('configFile', `query f($udoId: ID!) { configFile(udoId: $udoId) { ${CONFIG_FIELDS} content } }`, { udoId: assertSafeUdoId(udoId) }, { readOnly: true })
210
- : await sdlGraphql('configFile', `query f($id: ID!) { configFile(id: $id) { ${CONFIG_FIELDS} content } }`, { id: name }, { readOnly: true });
211
- return data?.configFile ?? null;
289
+ // Validate before the try: an invalid udoId is a caller bug, not a signal
290
+ // that the file is absent, and must never be swallowed by the absence path.
291
+ const safeUdoId = udoId ? assertSafeUdoId(udoId) : null;
292
+ try {
293
+ const data = safeUdoId
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 });
296
+ return data?.configFile ?? null;
297
+ } catch (err) {
298
+ // Only a GraphQL-layer error can mean "absent". A transport failure whose
299
+ // body happens to contain the words "not found" (a 404 page, a WAF block)
300
+ // must never be read as absence: that is how a delete gets confirmed
301
+ // against a file that was never checked.
302
+ if (!err.graphql) throw err;
303
+ if (/config file with (name|id) .* not found/i.test(err.message)) return null;
304
+
305
+ // The udoId form returns a generic message that a version conflict also
306
+ // returns, so settle it against the listing. If the listing itself fails,
307
+ // surface the ORIGINAL error with the listing failure attached rather than
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.
313
+ let all;
314
+ try {
315
+ all = await configFiles({ scope });
316
+ } catch (listErr) {
317
+ err.message += ` (absence check failed: ${listErr.message})`;
318
+ throw err;
319
+ }
320
+ const present = udoId
321
+ ? all.some(f => String(f.udoId) === String(udoId))
322
+ : all.some(f => matchesName(f, name));
323
+ if (!present) return null;
324
+ throw err;
325
+ }
212
326
  }
213
327
 
214
328
  /**
@@ -217,13 +331,16 @@ export async function configFile({ name, udoId }) {
217
331
  * - name given → updates in place for plain files, but CREATES A DUPLICATE
218
332
  * for /dashboards/. Never write a dashboard by name.
219
333
  */
220
- export async function putConfigFile({ name, udoId, content, expectedVersion }) {
334
+ export async function putConfigFile({ name, udoId, content, expectedVersion, scope }) {
221
335
  if (!name && !udoId) throw new Error('putConfigFile requires either name or udoId');
222
336
  // Creating a dashboard must go by name (no udoId exists yet); only an
223
337
  // *existing* dashboard is at risk of being duplicated by a name-addressed
224
338
  // write. So refuse only when a file of that name already exists.
225
- if (!udoId && String(name).startsWith('/dashboards/')) {
226
- const all = await configFiles();
339
+ if (!udoId && normaliseName(name).startsWith('/dashboards/')) {
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 });
227
344
  // An empty listing means the check could not run, not that the name is
228
345
  // free. Failing open here would silently disable the guard.
229
346
  if (!all.length) {
@@ -232,8 +349,7 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
232
349
  'duplicate check could not run. Retry, or pass an explicit udoId.'
233
350
  );
234
351
  }
235
- const key = String(name).trim().toLowerCase();
236
- const existing = all.filter(f => String(f.name || '').trim().toLowerCase() === key);
352
+ const existing = all.filter(f => matchesName(f, name));
237
353
  if (existing.length) {
238
354
  const ids = existing.map(f => f.udoId).filter(Boolean).join(', ');
239
355
  throw new Error(
@@ -251,10 +367,10 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
251
367
  const data = udoId
252
368
  ? await sdlGraphql('addConfigFile',
253
369
  `mutation f($udoId: ID, $content: String!, $expectedVersion: Long) { addConfigFile(udoId: $udoId, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
254
- { udoId: assertSafeUdoId(udoId), content, expectedVersion })
370
+ { udoId: assertSafeUdoId(udoId), content, expectedVersion }, { scope })
255
371
  : await sdlGraphql('addConfigFile',
256
372
  `mutation f($name: String, $content: String!, $expectedVersion: Long) { addConfigFile(name: $name, content: $content, expectedVersion: $expectedVersion) { ${CONFIG_FIELDS} } }`,
257
- { name, content, expectedVersion });
373
+ { name, content, expectedVersion }, { scope });
258
374
  return data?.addConfigFile ?? null;
259
375
  }
260
376
 
@@ -263,21 +379,21 @@ export async function putConfigFile({ name, udoId, content, expectedVersion }) {
263
379
  * A null return with no errors array is SUCCESS; the deleted object is not
264
380
  * echoed back. Treating that null as a failure is the classic mistake here.
265
381
  */
266
- export async function deleteConfigFile({ name, udoId, expectedVersion }) {
382
+ export async function deleteConfigFile({ name, udoId, expectedVersion, scope }) {
267
383
  if (!name && !udoId) throw new Error('deleteConfigFile requires either name or udoId');
268
384
  const raw = udoId
269
385
  ? await sdlGraphql('deleteConfigFile',
270
386
  'mutation f($udoId: ID, $expectedVersion: Long) { deleteConfigFile(udoId: $udoId, expectedVersion: $expectedVersion) { udoId } }',
271
- { udoId: assertSafeUdoId(udoId), expectedVersion })
387
+ { udoId: assertSafeUdoId(udoId), expectedVersion }, { scope })
272
388
  : await sdlGraphql('deleteConfigFile',
273
389
  'mutation f($id: ID, $expectedVersion: Long) { deleteConfigFile(id: $id, expectedVersion: $expectedVersion) { udoId } }',
274
- { id: name, expectedVersion });
390
+ { id: name, expectedVersion }, { scope });
275
391
 
276
392
  // The mutation returns null on success and does not echo the deleted object,
277
393
  // so its response cannot distinguish "deleted" from "matched nothing". Confirm
278
394
  // by re-reading. This is the house rule established by uamSetStatus in
279
395
  // lib/s1.js: never treat a mutation response as proof, re-get and verify.
280
- const still = await configFile({ name, udoId });
396
+ const still = await configFile({ name, udoId, scope });
281
397
  if (still) {
282
398
  throw new Error(
283
399
  `deleteConfigFile: ${udoId ? `udoId ${udoId}` : name} still exists after the delete mutation ` +
@@ -293,13 +409,275 @@ export async function deleteConfigFile({ name, udoId, expectedVersion }) {
293
409
  // exist" decision. The GraphQL operations above cover every namespace,
294
410
  // including parsers, lookups, datatables and /automaticLookups.
295
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
+
296
674
  // ─── V1 Query (schema discovery) ─────────────────────────────────────────────
297
675
  // Deprecated Feb 15 2027 but still the only way to get full event JSON per-event.
298
676
  // Use for schema discovery; use LRQ for hunting.
299
677
 
300
678
  /** POST /api/query: retrieve raw event JSON for schema discovery.
301
679
  * Returns { matches: [{ timestamp, message, attributes }] }. */
302
- export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime } = {}) {
680
+ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime, scope } = {}) {
303
681
  const body = {
304
682
  queryType: 'log',
305
683
  filter,
@@ -307,5 +685,7 @@ export async function v1Query(filter, { maxCount = 5, startTime = '24h', endTime
307
685
  startTime,
308
686
  };
309
687
  if (endTime) body.endTime = endTime;
310
- return sdlFetch('POST', '/api/query', { body });
688
+ // Read-only POST: opt back into status retry. Schema discovery iterates this
689
+ // once per data source, which is the workload that trips the SDL QPS cap.
690
+ return sdlFetch('POST', '/api/query', { body, allowRetry: true, extraHeaders: scopeHeaders(scope) });
311
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.2',
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.2",
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