@pmoses-s1/s1-secops-mcp 1.3.8 → 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 +109 -0
- package/README.md +46 -2
- package/deploy/README.md +4 -3
- package/lib/server-core.js +1 -1
- package/package.json +2 -2
- package/scripts/regen-readme-tools-table.mjs +2 -1
- package/tools/hyperautomation.js +52 -6
- package/tools/sdl-api.js +71 -7
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.
|
|
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.
|
|
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.
|
|
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/server-core.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmoses-s1/s1-secops-mcp",
|
|
3
|
-
"version": "1.3.
|
|
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) },
|
package/tools/hyperautomation.js
CHANGED
|
@@ -213,7 +213,7 @@ export const tools = [
|
|
|
213
213
|
properties: {
|
|
214
214
|
workflowJson: {
|
|
215
215
|
type: 'string',
|
|
216
|
-
description: '
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
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
|
-
|
|
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: {
|
|
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
|
|
183
|
-
|
|
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
|
|