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