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

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,114 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.9
4
+
5
+ Three defects from a field smoke test, plus the delivery-channel fault that kept
6
+ the last two releases from reaching the person who reported them. Tool count
7
+ stays 32.
8
+
9
+ ### The image tag now tracks the npm version
10
+
11
+ `IMAGE_VERSION` was a third free-running number, and the cost was a reused tag.
12
+ `s1-mcps:1.3.3` shipped npm 1.3.3, then 1.3.7, then 1.3.8, and the image version
13
+ once moved **backwards**, 1.3.7 to 1.3.3. With the documented `--pull=missing`
14
+ against a tag string that never changes, Docker has nothing to notice, so anyone
15
+ who pulled in August kept an August build while believing they were current. A
16
+ report of "sdl_list_files 500s at default scope" turned out to be exactly that:
17
+ a build from before the 1.3.4 and 1.3.5 scope work.
18
+
19
+ `IMAGE_VERSION` now defaults to `${S1_MCP_VERSION}`, so changing what is inside
20
+ the image always changes the tag. The documented Docker config moves to `:latest`
21
+ with **`--pull=always`**. Version tags remain published and immutable, for
22
+ reproducible demos and support.
23
+
24
+ The CI bump guard had two holes and both are closed. It tested that the
25
+ `IMAGE_VERSION` line *changed* rather than that the version *increased*, which
26
+ is how the backwards move passed; it now requires a strict semver increase. And
27
+ it permitted a publish whenever the tag was absent from the registry, which made
28
+ "delete the tag, republish different bytes" a supported route; that escape hatch
29
+ is gone. A tag someone already pulled does not become safe to reuse by being
30
+ deleted.
31
+
32
+ ### Fixed
33
+
34
+ - **`ha_import_workflow` double-wrapped the payload.** The endpoint's body is
35
+ `{"data": {...}}`, the hyperautomation skill's smoke-test example showed that
36
+ correctly, and this tool adds the envelope itself, so pasting the documented
37
+ example sent `{"data":{"data":{...}}}` and the API answered
38
+ `422 body.data.name Field required`, which reads like a broken workflow rather
39
+ than one extra level of nesting. Both artifacts were right in isolation and
40
+ nothing ever executed one against the other.
41
+
42
+ The tool now unwraps a wrapped payload when it is unambiguous, a `data` object
43
+ present and no top-level `name`, and says so in the response rather than
44
+ silently accepting both shapes. A workflow that legitimately owns a `data` key
45
+ keeps it. The skill now prints both forms, labelled: Form A the raw API body,
46
+ Form B the bare object this tool wants.
47
+
48
+ - **`sdl_list_dashboards` had no limit.** Measured on an MSSP account it returned
49
+ 442,581 characters across 17,111 lines, past any usable context budget. It now
50
+ takes `limit` (default 100, clamped to 1000), `offset` and `namesOnly`, and
51
+ reports `totalCount`, `hasMore` and `nextOffset`. `namesOnly` returns `{id,
52
+ name}` and is about four times smaller, measured, which covers the common case
53
+ of resolving a name to an id. `sdl_list_files` gets the same treatment,
54
+ default 500, with `count` still reporting the full post-filter total so
55
+ existing callers read the number they expect.
56
+
57
+ - **`ha_export_workflow` could not be scoped.** It sent no scope parameter, while
58
+ `ha_import_workflow` already documents that an unscoped call on a scoped tenant
59
+ returns a misleading `403 Insufficient permissions`. It now accepts
60
+ `accountIds` / `siteIds`, and an unscoped 403 names scope as a possible cause
61
+ instead of leaving the reader to conclude "missing role" and stop.
62
+
63
+ ### Documented, not fixed here
64
+
65
+ **Omitted parameters that declare a default are rejected by the client.** Calling
66
+ `powerquery_run`, `uam_list_alerts`, `ha_list_workflows` and four others without
67
+ passing every argument fails with `expected nonoptional, received undefined`
68
+ before the request reaches this server. That is not this package: it has no
69
+ dependencies and does not use zod, those are Zod v4 codes from the host, and the
70
+ error arrives pre-dispatch. The host maps a JSON-Schema property carrying
71
+ `default` to a non-optional field, validating against the schema's output type
72
+ rather than its input type. 7 of 32 tools and 16 parameters are affected, listed
73
+ in the README with the pass-every-parameter workaround. Reported upstream.
74
+
75
+ **A dashboard `id` is often null, and that is its age, not a fault.** Only
76
+ dashboards created through the `dashboardsV2` surface carry a udoId; ones created
77
+ the older name-addressed way do not. On an established account most predate it,
78
+ measured 397 of 1,555 with an id, and `sdl_list_files` reports exactly the same
79
+ 397. Address a legacy dashboard by its `/dashboards/<name>` path. Recorded in the
80
+ `sdl_list_dashboards` description, along with the fact that results are ordered by
81
+ name and that order is stable across calls, so page on `name` and not on `id`.
82
+
83
+ The `limit`, `offset` and `namesOnly` parameters added above deliberately carry
84
+ no `default` keyword for this reason; their defaults live in the handler and are
85
+ stated in the descriptions. A new test pins the set of default-bearing
86
+ properties so the blast radius cannot grow by accident.
87
+
88
+ ### Tests
89
+
90
+ 132 JS (+14). The 1.3.8 suite asserted structure, tool count, names, description
91
+ keywords, with the HTTP layer mocked, and all four defects lived in the space
92
+ that left uncovered. Four new classes:
93
+
94
+ - **Executable doc examples.** Both JSON blocks in the hyperautomation skill's
95
+ smoke-test section are parsed out of the markdown and run through
96
+ `ha_import_workflow`, asserting the outbound body carries exactly one `data`
97
+ envelope. A documented payload that cannot survive the tool beside it now fails
98
+ the build.
99
+ - **Outbound wire shape.** Assert the body that is sent, not merely that a call
100
+ was made.
101
+ - **Client-parity schema lint.** Every tool must be callable with only its
102
+ `required` fields; no property may be both required and defaulted; and the set
103
+ of default-bearing properties is frozen against the known list.
104
+ - **Response budget.** List tools are driven with MSSP-scale fixtures, 1,200
105
+ dashboards and 2,000 config files, and must stay under 200,000 characters.
106
+ Cardinality is a property of the tenant, so only a fixture can supply it.
107
+
108
+ ### Docker
109
+
110
+ Bundle image **1.3.9**, pinning npm 1.3.9. Image version now derived, not chosen.
111
+
3
112
  ## 1.3.7
4
113
 
5
114
  Minor, not patch: a tool is gone and the log-ingest credential changed. Both
package/README.md CHANGED
@@ -73,11 +73,12 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
73
73
  "mcpServers": {
74
74
  "s1-secops-mcp": {
75
75
  "command": "npx",
76
- "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.7"],
76
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.9"],
77
77
  "env": {
78
78
  "S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
79
79
  "S1_CONSOLE_API_TOKEN": "eyJ...",
80
- "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net"
80
+ "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net",
81
+ "S1_HEC_TOKEN": "<SDL Log Write Key, optional; hec_ingest needs it>"
81
82
  }
82
83
  }
83
84
  }
@@ -522,6 +523,49 @@ The `sentinelone://soc-context` resource and `soc_analyst` prompt load `CLAUDE.m
522
523
 
523
524
  For npx installs without a CLAUDE.md nearby, set `S1_CLAUDE_MD_PATH` in the `env` block of `claude_desktop_config.json` to point at the one in your Cowork project folder. Restart Claude Desktop to pick up edits.
524
525
 
526
+ ## Known client issue: omitted parameters that declare a default are rejected
527
+
528
+ On affected Claude Code / Claude Desktop builds, calling one of the tools below without
529
+ passing **every** parameter fails before the request reaches this server:
530
+
531
+ ```
532
+ MCP error -32602: Input validation error: Invalid arguments for tool powerquery_run: [
533
+ { "code": "invalid_type", "expected": "nonoptional", "path": ["maxRows"],
534
+ "message": "Invalid input: expected nonoptional, received undefined" } ]
535
+ ```
536
+
537
+ **This is not a defect in this server, and upgrading it will not fix it.** This package
538
+ has no dependencies and does not use zod; those are Zod v4 error codes, emitted by the
539
+ host, and the error arrives before dispatch. The host converts a tool's JSON Schema into
540
+ a validator and maps a property carrying `default` to a non-optional field, so an absent
541
+ value raises an error instead of taking the default. It validates against the schema's
542
+ *output* type rather than its *input* type. Any MCP server that declares defaults is
543
+ affected.
544
+
545
+ **Workaround:** pass every parameter explicitly, including the ones you want at their
546
+ default value.
547
+
548
+ **Affected tools and parameters**, 7 of 32:
549
+
550
+ | Tool | Parameters carrying a `default` |
551
+ |---|---|
552
+ | `ha_list_workflows` | `limit`, `skip`, `sortBy`, `sortOrder` |
553
+ | `uam_list_alerts` | `first`, `viewType` |
554
+ | `powerquery_run` | `hours`, `maxRows` |
555
+ | `powerquery_schema_discover` | `maxEvents`, `startTime` |
556
+ | `powerquery_enumerate_sources` | `hours` |
557
+ | `sdl_create_dashboard` | `isPublic` |
558
+ | `uam_ingest_alert` | `title`, `hostname`, `filename`, `inline` |
559
+
560
+ Every other tool is unaffected, and within these tools only the listed parameters are
561
+ rejected. Parameters without a default (`query`, `scope`, `startTime` on
562
+ `powerquery_run`, `status`, `severity`, `siteIds`) work when omitted.
563
+
564
+ Tools added in 1.3.9 (`limit`, `offset`, `namesOnly` on `sdl_list_dashboards` and
565
+ `sdl_list_files`) deliberately omit the `default` keyword and document their defaults in
566
+ the parameter description instead, so they are not affected. Reported upstream; when the
567
+ host is fixed, the defaults can go back into the schemas.
568
+
525
569
  ## Removed tools
526
570
 
527
571
  `purple_ai_query` and `purple_ai_investigate` were removed on 2026-05-03. Both required a browser-session `teamToken` from `/sdl/v2/graphql` that service-account API tokens never obtain (returns `AsimovError` / `SERVICE_ERROR`). Use `mcp__purple-mcp__purple_ai` instead, which holds the right credentials.
package/deploy/README.md CHANGED
@@ -34,7 +34,8 @@ 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
+ "S1_HEC_TOKEN": "<SDL Log Write Key, optional; hec_ingest needs it>"
38
39
  }
39
40
  ```
40
41
 
@@ -57,7 +58,7 @@ Or, equivalently, by package name without the install:
57
58
  "mcpServers": {
58
59
  "s1-secops-mcp": {
59
60
  "command": "npx",
60
- "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.7"]
61
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.9"]
61
62
  }
62
63
  }
63
64
  }
@@ -376,7 +377,7 @@ Both block the W+X memory mappings V8 needs to JIT JavaScript. Adding them cause
376
377
 
377
378
  These are supported but not first-class:
378
379
 
379
- - **Docker / docker-compose.** Not shipped in this version. The single-file Node binary doesn't need it. If you want a container, the install is `FROM node:20-alpine` + `RUN npm install -g @pmoses-s1/s1-secops-mcp@1.3.7` + `CMD ["s1-secops-mcp", "--transport", "http", "--host", "0.0.0.0"]`. Mount creds at `/etc/s1-secops-mcp/credentials.json` and tokens at `/etc/s1-secops-mcp/bearer-tokens.json`.
380
+ - **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.9` + `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
381
 
381
382
  - **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
383
 
package/lib/s1.js CHANGED
@@ -643,21 +643,18 @@ export async function uamSetStatus(alertId, status) {
643
643
  }
644
644
  if (action.failure?.length) {
645
645
  const f = action.failure[0];
646
- // errorMessage says WHAT failed, not WHY. `Missing UAM manage permissions`
647
- // is also what a not-offered action returns: alerts ingested via the UAM
648
- // Alert Interface (/v1/alerts) expose only addNote and eventSearch, while
649
- // the same token sets status on a native alert successfully (measured).
650
- // So ask alertAvailableActions before reporting a cause, and never let the
651
- // caller conclude "the token lacks permission" from the string alone.
646
+ // errorMessage names the failure, not the cause. Ask alertAvailableActions,
647
+ // which is filtered by the caller's permissions AND the alert type.
652
648
  let hint = '';
653
649
  try {
654
650
  const avail = await uamAvailableActions(alertId);
655
651
  const ids = avail.map((a) => a.id);
656
652
  if (!ids.includes('S1/alert/statusUpdate')) {
657
- hint = ' | alertAvailableActions: statusUpdate is NOT OFFERED for this '
658
- + `alert type (available: ${ids.join(', ') || 'none'}). This is a `
659
- + 'capability limit of the alert, not a token scope; a new token '
660
- + 'will not help.';
653
+ hint = ' | alertAvailableActions: statusUpdate is NOT OFFERED to this '
654
+ + `caller for this alert (available: ${ids.join(', ') || 'none'}). `
655
+ + 'Availability is filtered by the caller\'s permissions and the '
656
+ + 'alert type: check the service user\'s UAM permissions. A '
657
+ + 'console user session may still be able to perform it.';
661
658
  } else {
662
659
  const a = avail.find((x) => x.id === 'S1/alert/statusUpdate');
663
660
  hint = a?.isDisabled
@@ -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.7',
97
+ version: '1.3.9',
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.7",
3
+ "version": "1.3.9",
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",
@@ -20,7 +20,7 @@
20
20
  "start": "node index.js",
21
21
  "start:http": "node index.js --transport http",
22
22
  "dev": "node --watch index.js",
23
- "test": "node --test tests/smoke.test.mjs tests/stdio-transport.test.mjs tests/http-transport.test.mjs tests/ssrf-path.test.mjs tests/http-origin-guard.test.mjs tests/sdl-graphql.test.mjs tests/regressions-2026-07-29.test.mjs tests/regressions-2026-07-31.test.mjs",
23
+ "test": "node --test tests/smoke.test.mjs tests/stdio-transport.test.mjs tests/http-transport.test.mjs tests/ssrf-path.test.mjs tests/http-origin-guard.test.mjs tests/sdl-graphql.test.mjs tests/regressions-2026-07-29.test.mjs tests/regressions-2026-07-31.test.mjs tests/contracts-1.3.9.test.mjs",
24
24
  "regen:readme": "node scripts/regen-readme-tools-table.mjs"
25
25
  },
26
26
  "engines": {
@@ -39,6 +39,7 @@ const TOOL_SKILL = {
39
39
  uam_get_alert: 'mgmt-console-api',
40
40
  uam_add_note: 'mgmt-console-api',
41
41
  uam_set_status: 'mgmt-console-api',
42
+ uam_available_actions: 'mgmt-console-api',
42
43
  // SDL API
43
44
  sdl_list_files: 'sdl-api / sdl-dashboard / sdl-log-parser',
44
45
  sdl_get_file: 'sdl-api / sdl-dashboard / sdl-log-parser',
@@ -65,7 +66,7 @@ const TOOL_SKILL = {
65
66
 
66
67
  const GROUPS = [
67
68
  { label: 'PowerQuery', prefix: 'powerquery_' },
68
- { label: 'Mgmt Console', test: n => /^(s1_api_|purple_ai_|uam_(list|get|add|set))/.test(n) },
69
+ { label: 'Mgmt Console', test: n => /^(s1_api_|purple_ai_|uam_(list|get|add|set|available))/.test(n) },
69
70
  { label: 'SDL API', test: n => n.startsWith('sdl_') || n === 'hec_ingest' },
70
71
  { label: 'Hyperautomation', prefix: 'ha_' },
71
72
  { label: 'UAM Ingest', test: n => /^(uam_ingest_|uam_post_)/.test(n) },
@@ -213,7 +213,7 @@ export const tools = [
213
213
  properties: {
214
214
  workflowJson: {
215
215
  type: 'string',
216
- description: 'Full Hyperautomation workflow JSON as a string. Must be valid Hyperautomation schema. Generate this using the hyperautomation skill.',
216
+ description: 'The workflow object as a JSON string: {"name": ..., "description": ..., "actions": [...]}. Pass the BARE workflow, not the {"data": {...}} request envelope; this tool adds the envelope itself. A data-wrapped payload is unwrapped automatically and noted in the response, because the raw-API examples in the hyperautomation skill show the on-the-wire body, which already includes that envelope. Generate the workflow using the hyperautomation skill.',
217
217
  },
218
218
  accountIds: {
219
219
  type: 'string',
@@ -233,6 +233,26 @@ export const tools = [
233
233
  } catch (e) {
234
234
  return JSON.stringify({ error: `Invalid JSON: ${e.message}` });
235
235
  }
236
+ // Accept the wire-shaped payload as well as the bare workflow.
237
+ //
238
+ // The import endpoint's body is {"data": {...}} and the skill's raw-API examples
239
+ // show it that way, correctly. This tool supplies that envelope, so pasting a
240
+ // documented example sent {"data":{"data":{...}}} and the API answered 422
241
+ // `body.data.name Field required`, which reads like a schema problem in the
242
+ // workflow rather than one extra level of nesting.
243
+ //
244
+ // Unwrap only when it is unambiguous: a `data` object present AND no top-level
245
+ // `name`. A bare workflow legitimately carrying its own `data` key keeps it.
246
+ let unwrapped = false;
247
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)
248
+ && parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)
249
+ && parsed.name === undefined) {
250
+ parsed = parsed.data;
251
+ unwrapped = true;
252
+ process.stderr.write(
253
+ '[ha_import_workflow] payload arrived wrapped in {"data": ...}; unwrapped before sending. ' +
254
+ 'Pass the bare workflow object to this tool; the "data" envelope is for raw API calls.\n');
255
+ }
236
256
  // Public import endpoint. Append the scope query param: account-level imports use
237
257
  // ?accountIds=, site-level use ?siteIds=. A bare import (no scope) returns a misleading
238
258
  // 403 on a scoped tenant (validated 2026-06-13).
@@ -240,6 +260,13 @@ export const tools = [
240
260
  if (accountIds) qs = `?accountIds=${encodeURIComponent(accountIds)}`;
241
261
  else if (siteIds) qs = `?siteIds=${encodeURIComponent(siteIds)}`;
242
262
  const result = await apiPost(`${HA_PUBLIC}/workflow-import-export/import${qs}`, { data: parsed });
263
+ if (unwrapped) {
264
+ return JSON.stringify({
265
+ importNote: 'The payload arrived wrapped in {"data": ...} and was unwrapped before sending. ' +
266
+ 'That envelope belongs to raw API calls; pass the bare workflow object to this tool.',
267
+ ...result,
268
+ }, null, 2);
269
+ }
243
270
  return JSON.stringify(result, null, 2);
244
271
  },
245
272
  },
@@ -247,13 +274,22 @@ export const tools = [
247
274
  // ─── ha_export_workflow ───────────────────────────────────────────────────
248
275
  {
249
276
  name: 'ha_export_workflow',
250
- description: `Export all Hyperautomation workflows as a ZIP archive. Returns metadata about the ZIP (size, content-type) plus the first 200 bytes of the base64-encoded content. NOTE: The export API (confirmed via live backtest) returns ALL workflows; there is no per-workflow filter. Use ha_get_workflow to read a specific workflow's JSON definition instead. Export/import endpoints were not captured in the v1 network trace; this tool uses the confirmed /public path.`,
277
+ description: `Export Hyperautomation workflows as a ZIP archive. Returns metadata about the ZIP (size, content-type) plus the first 200 bytes of the base64-encoded content. NOTE: there is no per-workflow filter; the API returns every workflow in scope. Use ha_get_workflow to read a specific workflow's JSON definition instead. Scope with accountIds or siteIds: on a scoped tenant an unscoped call can return a 403 "Insufficient permissions" that is really a scoping problem, the same misleading failure ha_import_workflow documents. Requires Hyper Automate.view permission.`,
251
278
  inputSchema: {
252
279
  type: 'object',
253
- properties: {},
280
+ properties: {
281
+ accountIds: {
282
+ type: 'string',
283
+ description: 'Account scope for an account-level export (e.g. "2046190533732727925"). Provide this OR siteIds. Omit to use the token default.',
284
+ },
285
+ siteIds: {
286
+ type: 'string',
287
+ description: 'Site scope for a site-level export. Provide this OR accountIds.',
288
+ },
289
+ },
254
290
  required: [],
255
291
  },
256
- async handler() {
292
+ async handler({ accountIds, siteIds } = {}) {
257
293
  // Export path confirmed working during backtest at /public path.
258
294
  // GET returns binary ZIP of ALL workflows; POST returns 405.
259
295
  // Per-workflow filter is not supported by this API version.
@@ -261,7 +297,10 @@ export const tools = [
261
297
  const creds = getCreds();
262
298
  const base = creds.S1_CONSOLE_URL.replace(/\/+$/, '');
263
299
  const tok = creds.S1_CONSOLE_API_TOKEN;
264
- const url = `${base}${HA_PUBLIC}/workflow-import-export/export`;
300
+ let qs = '';
301
+ if (accountIds) qs = `?accountIds=${encodeURIComponent(accountIds)}`;
302
+ else if (siteIds) qs = `?siteIds=${encodeURIComponent(siteIds)}`;
303
+ const url = `${base}${HA_PUBLIC}/workflow-import-export/export${qs}`;
265
304
 
266
305
  const res = await fetch(url, {
267
306
  method: 'GET',
@@ -269,7 +308,14 @@ export const tools = [
269
308
  });
270
309
  if (!res.ok) {
271
310
  const text = await res.text();
272
- throw new Error(`ha_export_workflow ${res.status}: ${text}`);
311
+ // A 403 here has two causes and the message does not distinguish them, so name
312
+ // both rather than letting the reader assume it is only about roles.
313
+ const hint = res.status === 403 && !qs
314
+ ? '. Two possible causes: the token lacks Hyper Automate.view, OR this is a' +
315
+ ' scoped tenant and the call needs accountIds/siteIds. Retry with a scope' +
316
+ ' before concluding it is a permission problem.'
317
+ : '';
318
+ throw new Error(`ha_export_workflow → ${res.status}: ${text}${hint}`);
273
319
  }
274
320
  const buf = await res.arrayBuffer();
275
321
  const base64 = Buffer.from(buf).toString('base64');
@@ -346,7 +346,7 @@ export const tools = [
346
346
  // ─── uam_available_actions ────────────────────────────────────────────────
347
347
  {
348
348
  name: 'uam_available_actions',
349
- description: `Ask the API which actions can be triggered on a UAM alert, with isDisabled and disabledReason per action. This is the authoritative capability answer and the ONLY correct way to explain a refused write. A refused action returns errorMessage "Missing UAM manage permissions" whether the token lacks a scope OR the action is simply not offered for that alert type, and those need opposite responses. Measured on one tenant with one token: an alert ingested via the UAM Alert Interface (/v1/alerts) offers only S1/alert/addNote and S1/alert/eventSearch, so uam_set_status can never work on it and no token change helps; a native STAR / third-party / correlation alert offers S1/alert/statusUpdate and S1/alert/analystVerdictUpdate and the same token applies them successfully. Call this before concluding anything about permissions, and before a bulk write you cannot undo. Availability is also scope-sensitive: the S1/incident/* actions report INCIDENT_ACTIONS_ONLY_AVAILABLE_FROM_SITE_VIEW under ACCOUNT scope and are enabled under SITE, so pass scopeIds/scopeType when you care about a site-scoped action. Read-only.`,
349
+ description: `Ask the API which actions can be triggered on a UAM alert, with isDisabled and disabledReason per action. This is the authoritative capability answer and the ONLY correct way to explain a refused write. Availability is filtered by the caller's permissions AND the alert type: one service-user token is offered S1/alert/statusUpdate on a native STAR alert and not on an alert ingested via the UAM Alert Interface (/v1/alerts), where only S1/alert/addNote and S1/alert/eventSearch are listed, while a console user session performs the identical mutation on either. So an action missing here means this identity lacks the permission for this alert type, not that it is impossible: check the service user's UAM permissions. Call this before concluding anything about permissions, and before a bulk write you cannot undo. Availability is also scope-sensitive: the S1/incident/* actions report INCIDENT_ACTIONS_ONLY_AVAILABLE_FROM_SITE_VIEW under ACCOUNT scope and are enabled under SITE, so pass scopeIds/scopeType when you care about a site-scoped action. Read-only.`,
350
350
  inputSchema: {
351
351
  type: 'object',
352
352
  properties: {
package/tools/sdl-api.js CHANGED
@@ -65,13 +65,38 @@ export const tools = [
65
65
  description: 'Optional filter, e.g. "/dashboards/" or "/logParsers/". Applied client-side to the full listing.',
66
66
  },
67
67
  scope: scopeProp,
68
+ // See the note in sdl_list_dashboards: no `default:` keywords, because the host
69
+ // currently rejects any omitted property that declares one.
70
+ limit: {
71
+ type: 'number',
72
+ description: 'Maximum files to return. Defaults to 500, clamped to 5000. Narrow with pathPrefix first; paging a filtered list is cheaper than paging everything.',
73
+ },
74
+ offset: {
75
+ type: 'number',
76
+ description: 'Index to start from. Defaults to 0. Page with offset += limit while offset is less than totalCount.',
77
+ },
68
78
  },
69
79
  required: [],
70
80
  },
71
- async handler({ pathPrefix, scope } = {}) {
81
+ async handler({ pathPrefix, scope, limit = 500, offset = 0 } = {}) {
72
82
  let files = await configFiles({ scope });
73
83
  if (pathPrefix) files = files.filter(f => (f.name || '').startsWith(pathPrefix));
74
- return JSON.stringify({ count: files.length, scope: scope ?? null, files }, null, 2);
84
+ const lim = Math.max(1, Math.min(Number(limit) || 500, 5000));
85
+ const off = Math.max(0, Number(offset) || 0);
86
+ const page = files.slice(off, off + lim);
87
+ const returnedEnd = off + page.length;
88
+ return JSON.stringify({
89
+ // `count` kept as the full post-filter total so existing callers that read it
90
+ // still get the number they expect; `returned` is the size of this page.
91
+ count: files.length,
92
+ returned: page.length,
93
+ offset: off,
94
+ limit: lim,
95
+ hasMore: returnedEnd < files.length,
96
+ nextOffset: returnedEnd < files.length ? returnedEnd : null,
97
+ scope: scope ?? null,
98
+ files: page,
99
+ }, null, 2);
75
100
  },
76
101
  },
77
102
 
@@ -172,15 +197,54 @@ export const tools = [
172
197
  // ─── sdl_list_dashboards ──────────────────────────────────────────────────
173
198
  {
174
199
  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}`,
200
+ 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. PAGINATED: the full listing is unbounded and on a large tenant it is enormous (measured on an MSSP account: 442,581 characters, 17,111 lines, far past any usable context budget), so this returns at most "limit" dashboards starting at "offset", newest API order preserved. totalCount always reports the full number so you can tell when you are seeing a page. Use namesOnly to get just {id, name} when you are resolving a name to an id, which is the common case and roughly four times smaller. NOTE that "id" is null for dashboards created the old, name-addressed way, before udoIds existed; only dashboards created through the dashboardsV2 surface carry one. On an established account most predate it: measured 397 of 1,555 with an id, and sdl_list_files reports exactly the same 397, so a null is the object's age and not a gap in this tool. Address a legacy dashboard by its "/dashboards/<name>" path. Results come back ordered by name and that order is stable across calls, so offset paging is safe; page on "name", not on "id". ${SCOPE_NOTE}`,
176
201
  inputSchema: {
177
202
  type: 'object',
178
- properties: { scope: scopeProp },
203
+ properties: {
204
+ scope: scopeProp,
205
+ // No `default:` keywords here deliberately. The Claude Code host currently
206
+ // converts a JSON-Schema `default` into a non-optional Zod field, so a property
207
+ // carrying one is REJECTED when omitted ("expected nonoptional, received
208
+ // undefined") before the call ever reaches this server. Declaring defaults here
209
+ // would make sdl_list_dashboards unusable without passing every argument, which
210
+ // is the bug this release documents. Defaults live in the handler signature and
211
+ // are stated in these descriptions instead.
212
+ limit: {
213
+ type: 'number',
214
+ description: 'Maximum dashboards to return. Defaults to 100, clamped to 1000. The full listing is unbounded; this cap is what stops a large tenant blowing the context budget.',
215
+ },
216
+ offset: {
217
+ type: 'number',
218
+ description: 'Index to start from. Defaults to 0. Page with offset += limit while offset is less than totalCount.',
219
+ },
220
+ namesOnly: {
221
+ type: 'boolean',
222
+ description: 'Return only {id, name} per dashboard. Defaults to false. Much smaller; use when resolving a name to an id.',
223
+ },
224
+ },
179
225
  required: [],
180
226
  },
181
- async handler({ scope } = {}) {
182
- const dashboards = await listDashboards({ scope });
183
- return JSON.stringify({ count: dashboards.length, scope: scope ?? null, dashboards }, null, 2);
227
+ async handler({ scope, limit = 100, offset = 0, namesOnly = false } = {}) {
228
+ const all = await listDashboards({ scope });
229
+ // Clamp rather than reject: a caller asking for 100000 wants "all of them", and
230
+ // failing the call teaches nothing. The cap is what protects the context budget.
231
+ const lim = Math.max(1, Math.min(Number(limit) || 100, 1000));
232
+ const off = Math.max(0, Number(offset) || 0);
233
+ const page = all.slice(off, off + lim);
234
+ const dashboards = namesOnly
235
+ ? page.map(d => ({ id: d.id, name: d.name }))
236
+ : page;
237
+ const returnedEnd = off + dashboards.length;
238
+ return JSON.stringify({
239
+ totalCount: all.length,
240
+ returned: dashboards.length,
241
+ offset: off,
242
+ limit: lim,
243
+ hasMore: returnedEnd < all.length,
244
+ nextOffset: returnedEnd < all.length ? returnedEnd : null,
245
+ scope: scope ?? null,
246
+ dashboards,
247
+ }, null, 2);
184
248
  },
185
249
  },
186
250