@pmoses-s1/s1-secops-mcp 1.3.0

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.
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # smoke-test-http.sh -- generic HTTP smoke test for s1-secops-mcp.
4
+ #
5
+ # Exercises the public contract end-to-end:
6
+ # 1. healthz returns 200 (no auth)
7
+ # 2. initialize returns the expected protocol version and server info
8
+ # 3. tools/list returns 26 tools
9
+ # 4. tools/call s1_api_get works (uses /agents/count as a cheap probe)
10
+ # 5. bad bearer returns HTTP 401
11
+ # 6. unknown method returns JSON-RPC error -32601 inside a 200 envelope
12
+ #
13
+ # Useful for new team members verifying their setup, or for re-checking the
14
+ # deployment after a config change, a cert rotation, or an MCP version bump.
15
+ #
16
+ # Run as:
17
+ # MCP_HOST=mcp.s1.internal:8764 \
18
+ # MCP_BEARER=your-bearer-token \
19
+ # bash s1-secops-mcp/scripts/smoke-test-http.sh
20
+ #
21
+ # Or set defaults at the top of the script and run with no args.
22
+
23
+ set -uo pipefail
24
+
25
+ HOST="${MCP_HOST:-}"
26
+ TOKEN="${MCP_BEARER:-}"
27
+
28
+ if [[ -z "$HOST" || -z "$TOKEN" ]]; then
29
+ cat >&2 <<EOF
30
+ Usage:
31
+ MCP_HOST=<host:port> MCP_BEARER=<token> bash $0
32
+
33
+ Both env vars are required.
34
+ EOF
35
+ exit 2
36
+ fi
37
+
38
+ for tool in curl jq; do
39
+ if ! command -v "$tool" >/dev/null 2>&1; then
40
+ echo "Missing dependency: $tool" >&2
41
+ exit 3
42
+ fi
43
+ done
44
+
45
+ URL="https://$HOST/mcp"
46
+ AUTH="Authorization: Bearer $TOKEN"
47
+ JSON="Content-Type: application/json"
48
+
49
+ FAILED=0
50
+ fail() {
51
+ echo " FAIL: $*" >&2
52
+ FAILED=$((FAILED + 1))
53
+ }
54
+ pass() { echo " PASS: $*"; }
55
+
56
+ echo "=== 1. healthz (no auth) ==="
57
+ HEALTHZ_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$HOST/healthz")
58
+ [[ "$HEALTHZ_CODE" == "200" ]] && pass "healthz returned 200" || fail "healthz returned $HEALTHZ_CODE (expected 200)"
59
+
60
+ echo
61
+ echo "=== 2. initialize ==="
62
+ INIT_BODY=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" -d '{
63
+ "jsonrpc": "2.0",
64
+ "id": 1,
65
+ "method": "initialize",
66
+ "params": {
67
+ "protocolVersion": "2024-11-05",
68
+ "capabilities": {},
69
+ "clientInfo": { "name": "smoke-test", "version": "1" }
70
+ }
71
+ }')
72
+ PROTO=$(echo "$INIT_BODY" | jq -r '.result.protocolVersion // "missing"')
73
+ NAME=$(echo "$INIT_BODY" | jq -r '.result.serverInfo.name // "missing"')
74
+ VER=$(echo "$INIT_BODY" | jq -r '.result.serverInfo.version // "missing"')
75
+ [[ "$PROTO" == "2024-11-05" ]] && pass "protocolVersion=$PROTO" || fail "protocolVersion=$PROTO"
76
+ [[ "$NAME" == "s1-secops-mcp-server" ]] && pass "serverInfo.name=$NAME" || fail "serverInfo.name=$NAME"
77
+ [[ "$VER" != "missing" ]] && pass "serverInfo.version=$VER" || fail "serverInfo.version missing"
78
+
79
+ echo
80
+ echo "=== 3. tools/list count ==="
81
+ TOOLS_COUNT=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
82
+ -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' |
83
+ jq '.result.tools | length')
84
+ [[ "$TOOLS_COUNT" == "26" ]] && pass "tools/list returned 26 tools" || fail "tools/list returned $TOOLS_COUNT"
85
+
86
+ echo
87
+ echo "=== 4. tools/call s1_api_get on /agents/count ==="
88
+ AGENTS_TOTAL=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" -d '{
89
+ "jsonrpc": "2.0",
90
+ "id": 3,
91
+ "method": "tools/call",
92
+ "params": {
93
+ "name": "s1_api_get",
94
+ "arguments": { "path": "/web/api/v2.1/agents/count" }
95
+ }
96
+ }' | jq -r '.result.content[0].text' | jq -r '.data.total // "missing"')
97
+ if [[ "$AGENTS_TOTAL" =~ ^[0-9]+$ ]]; then
98
+ pass "s1_api_get returned $AGENTS_TOTAL agents"
99
+ else
100
+ fail "s1_api_get returned data.total=$AGENTS_TOTAL"
101
+ fi
102
+
103
+ echo
104
+ echo "=== 5. bad bearer (expect HTTP 401) ==="
105
+ BAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$URL" \
106
+ -H "Authorization: Bearer wrong-token-of-sufficient-length-1234567890" -H "$JSON" \
107
+ -d '{"jsonrpc":"2.0","id":99,"method":"tools/list"}')
108
+ [[ "$BAD_CODE" == "401" ]] && pass "bad bearer rejected with 401" || fail "bad bearer returned $BAD_CODE (expected 401)"
109
+
110
+ echo
111
+ echo "=== 6. method not found (expect -32601) ==="
112
+ ERR_CODE=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
113
+ -d '{"jsonrpc":"2.0","id":4,"method":"does/not/exist"}' |
114
+ jq -r '.error.code // "missing"')
115
+ [[ "$ERR_CODE" == "-32601" ]] && pass "unknown method returned -32601" || fail "unknown method returned code=$ERR_CODE"
116
+
117
+ echo
118
+ echo "=== Summary ==="
119
+ if [[ "$FAILED" -eq 0 ]]; then
120
+ echo "All checks passed."
121
+ exit 0
122
+ else
123
+ echo "$FAILED check(s) failed."
124
+ exit 1
125
+ fi
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Mac validation script for s1-secops-mcp (version read from package.json).
4
+ #
5
+ # Runs the same test matrix as Linux:
6
+ # - syntax-check every .js/.mjs file
7
+ # - npm test (smoke + stdio + HTTP + regression suites)
8
+ # - regen:readme --check (no doc drift)
9
+ # - sanity-run the server in stdio and HTTP modes
10
+ #
11
+ # Run this from the s1-secops-mcp/ directory on your Mac:
12
+ #
13
+ # cd ~/path/to/claude-skills/s1-secops-mcp
14
+ # bash scripts/test-mac.sh
15
+ #
16
+ # All checks should pass with green PASS markers. Any FAIL line should be
17
+ # reported back so the issue can be diagnosed.
18
+
19
+ set -uo pipefail
20
+
21
+ PASS_COUNT=0
22
+ FAIL_COUNT=0
23
+
24
+ green() { printf '\033[32m%s\033[0m' "$*"; }
25
+ red() { printf '\033[31m%s\033[0m' "$*"; }
26
+ bold() { printf '\033[1m%s\033[0m' "$*"; }
27
+
28
+ pass() {
29
+ printf ' %s %s\n' "$(green PASS)" "$1"
30
+ PASS_COUNT=$((PASS_COUNT + 1))
31
+ }
32
+ fail() {
33
+ printf ' %s %s\n %s\n' "$(red FAIL)" "$1" "${2:-}"
34
+ FAIL_COUNT=$((FAIL_COUNT + 1))
35
+ }
36
+ step() { printf '\n%s\n' "$(bold "$1")"; }
37
+
38
+ # ─── 1. Environment ───────────────────────────────────────────────────────────
39
+
40
+ step "1. Environment"
41
+ if [[ "$(uname -s)" != "Darwin" ]]; then
42
+ echo " WARNING: this script targets macOS but you're on $(uname -s). Continuing anyway."
43
+ fi
44
+ if ! command -v node >/dev/null 2>&1; then
45
+ fail "node not on PATH" "Install Node 18+: brew install node@20"
46
+ exit 1
47
+ fi
48
+ NODE_MAJOR="$(node --version | sed 's/^v\([0-9]*\).*/\1/')"
49
+ if [[ "$NODE_MAJOR" -lt 18 ]]; then
50
+ fail "Node $(node --version) is too old" "Need Node 18+"
51
+ exit 1
52
+ fi
53
+ pass "Node $(node --version) on $(uname -srm)"
54
+
55
+ if [[ ! -f "index.js" ]]; then
56
+ fail "Not in the s1-secops-mcp directory" "cd to .../claude-skills/s1-secops-mcp first"
57
+ exit 1
58
+ fi
59
+ pass "running from s1-secops-mcp directory"
60
+
61
+ # ─── 2. Syntax check ──────────────────────────────────────────────────────────
62
+
63
+ step "2. Syntax check"
64
+ for f in index.js lib/*.js tests/*.mjs scripts/*.mjs; do
65
+ [[ -f "$f" ]] || continue
66
+ if node --check "$f" 2>/dev/null; then
67
+ pass "$f"
68
+ else
69
+ out="$(node --check "$f" 2>&1)"
70
+ fail "$f" "$out"
71
+ fi
72
+ done
73
+
74
+ # ─── 3. npm test (22 assertions across smoke + stdio + HTTP) ─────────────────
75
+
76
+ step "3. Test suite (npm test)"
77
+ # Node 18-22 default to the TAP reporter, Node 23+ default to spec. Both
78
+ # reporters print the same passing assertions but with different summary
79
+ # lines. We check exit code first (authoritative) and then count assertions.
80
+ TEST_LOG="/tmp/mcp-test-mac.npm-test.log"
81
+ if npm test >"$TEST_LOG" 2>&1; then
82
+ # Count passing assertions across both reporter formats:
83
+ # TAP: "ok 1 - <name>"
84
+ # spec: "✔ <name>" (U+2714 HEAVY CHECK MARK)
85
+ TAP_OKS="$(grep -cE '^ok [0-9]+' "$TEST_LOG" || true)"
86
+ SPEC_OKS="$(grep -cE '^[[:space:]]*✔' "$TEST_LOG" || true)"
87
+ TOTAL=$((TAP_OKS + SPEC_OKS))
88
+ if [[ "$TOTAL" -ge 22 ]]; then
89
+ pass "$TOTAL passing assertions (npm test exit 0)"
90
+ else
91
+ fail "npm test exited 0 but only $TOTAL passing lines found" "$(tail -30 "$TEST_LOG")"
92
+ fi
93
+ else
94
+ fail "npm test exited non-zero" "$(tail -30 "$TEST_LOG")"
95
+ fi
96
+
97
+ # ─── 4. README/code drift check ───────────────────────────────────────────────
98
+
99
+ step "4. README/code drift check"
100
+ if npm run regen:readme -- --check >/dev/null 2>&1; then
101
+ pass "README tools table in sync with ALL_TOOLS"
102
+ else
103
+ fail "README tools table is stale" "Run: npm run regen:readme"
104
+ fi
105
+
106
+ # ─── 5. CLI flag sanity ───────────────────────────────────────────────────────
107
+
108
+ step "5. CLI flag sanity"
109
+ # Read the expected version from package.json so this check never goes stale.
110
+ PKG_VERSION="$(node -p "require('./package.json').version")"
111
+ if [[ "$(node index.js --version 2>/dev/null)" == "$PKG_VERSION" ]]; then
112
+ pass "--version returns $PKG_VERSION"
113
+ else
114
+ fail "--version did not return $PKG_VERSION" "Got: $(node index.js --version 2>&1)"
115
+ fi
116
+ if node index.js --help 2>&1 | grep -q "s1-secops-mcp $PKG_VERSION"; then
117
+ pass "--help renders"
118
+ else
119
+ fail "--help broken" "$(node index.js --help 2>&1 | head -5)"
120
+ fi
121
+
122
+ # ─── 6. stdio round-trip ──────────────────────────────────────────────────────
123
+
124
+ step "6. stdio round-trip"
125
+ STDIO_REPLY="$(printf '%s\n%s\n' \
126
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"mac-test","version":"1"}}}' \
127
+ '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' |
128
+ node index.js 2>/dev/null)"
129
+
130
+ TOOL_COUNT="$(echo "$STDIO_REPLY" | tail -n 1 | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{console.log(JSON.parse(s).result.tools.length)}catch(e){console.log("ERR")}})')"
131
+ if [[ "$TOOL_COUNT" == "26" ]]; then
132
+ pass "stdio tools/list returned 26 tools"
133
+ else
134
+ fail "stdio tools/list returned $TOOL_COUNT (expected 26)" "$STDIO_REPLY"
135
+ fi
136
+
137
+ # ─── 7. HTTP transport round-trip ─────────────────────────────────────────────
138
+
139
+ step "7. HTTP transport round-trip"
140
+ PORT=$((10000 + RANDOM % 1000))
141
+ node index.js --transport http --port "$PORT" --host 127.0.0.1 >/tmp/mcp-test-mac.log 2>&1 &
142
+ SERVER_PID=$!
143
+ trap 'kill $SERVER_PID 2>/dev/null || true' EXIT
144
+
145
+ # Wait for healthz
146
+ for i in $(seq 1 30); do
147
+ if curl -sf "http://127.0.0.1:$PORT/healthz" >/dev/null 2>&1; then
148
+ pass "HTTP server up on port $PORT"
149
+ break
150
+ fi
151
+ sleep 0.2
152
+ if [[ "$i" -eq 30 ]]; then
153
+ fail "HTTP server did not start within 6s" "$(cat /tmp/mcp-test-mac.log)"
154
+ exit 1
155
+ fi
156
+ done
157
+
158
+ HTTP_REPLY="$(curl -sf -X POST "http://127.0.0.1:$PORT/mcp" \
159
+ -H 'Content-Type: application/json' \
160
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}')"
161
+ HTTP_COUNT="$(echo "$HTTP_REPLY" | node -e 'let s=""; process.stdin.on("data",d=>s+=d); process.stdin.on("end",()=>{try{console.log(JSON.parse(s).result.tools.length)}catch(e){console.log("ERR")}})')"
162
+ if [[ "$HTTP_COUNT" == "26" ]]; then
163
+ pass "HTTP tools/list returned 26 tools"
164
+ else
165
+ fail "HTTP tools/list returned $HTTP_COUNT" "$HTTP_REPLY"
166
+ fi
167
+
168
+ # Auth: with no token loaded the server should accept unauthenticated requests
169
+ # (single-user local-only mode). Confirmed by the 26-tools response above.
170
+ pass "no-auth mode allows requests (single-user local)"
171
+
172
+ kill $SERVER_PID 2>/dev/null || true
173
+ trap - EXIT
174
+
175
+ # ─── Summary ──────────────────────────────────────────────────────────────────
176
+
177
+ step "Summary"
178
+ printf " Passed: %s\n" "$(green "$PASS_COUNT")"
179
+ if [[ "$FAIL_COUNT" -gt 0 ]]; then
180
+ printf " Failed: %s\n\n" "$(red "$FAIL_COUNT")"
181
+ echo "If any FAILs above, paste them so the issue can be diagnosed."
182
+ exit 1
183
+ else
184
+ printf " Failed: %s\n\n" "$FAIL_COUNT"
185
+ echo "$(green 'All checks passed on macOS.')"
186
+ exit 0
187
+ fi
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Hyperautomation tools: hyperautomation skill
3
+ *
4
+ * Tools:
5
+ * ha_list_workflows List Hyperautomation workflows (with scope/state/sort filters)
6
+ * ha_get_workflow Get a single workflow by ID (+ optional revisionId)
7
+ * ha_delete_workflow Delete (soft, recoverable) a workflow via REST DELETE
8
+ * ha_import_workflow Import (create) a workflow from JSON (account- or site-scoped)
9
+ * ha_export_workflow Export all workflows as a ZIP archive
10
+ *
11
+ * API root (confirmed via live network capture 2026-05-03):
12
+ * /web/api/v2.1/hyper-automate/api/v1
13
+ *
14
+ * Single-workflow fetch requires BOTH workflowId AND revisionId:
15
+ * GET /workflows/single/{workflowId}/{revisionId}
16
+ * The revisionId is workflow.version_id in the list response.
17
+ *
18
+ * Deletion is a soft, recoverable HTTP DELETE (validated 2026-06-13):
19
+ * DELETE /workflows/{id}?accountIds=<acct> (or ?siteIds=<site>)
20
+ * The older POST /workflows/archive returns 500 on this tenant: do not use it.
21
+ *
22
+ * Import scope: the public import endpoint accepts ?accountIds=<acct> or
23
+ * ?siteIds=<site>; with no scope it returns a misleading 403 on a scoped tenant.
24
+ */
25
+
26
+ import { apiGet, apiPost, apiDelete } from '../lib/s1.js';
27
+
28
+ // Confirmed base path from live network monitor (2026-05-03).
29
+ const HA_BASE = '/web/api/v2.1/hyper-automate/api/v1';
30
+
31
+ // Export/import confirmed on /public path during backtest; not re-captured under /v1.
32
+ const HA_PUBLIC = '/web/api/v2.1/hyper-automate/api/public';
33
+
34
+ export const tools = [
35
+ // ─── ha_list_workflows ────────────────────────────────────────────────────
36
+ {
37
+ name: 'ha_list_workflows',
38
+ description: `List SentinelOne Hyperautomation workflows. Returns workflow ID, version_id (revisionId for ha_get_workflow), name, state, status, trigger types, action types, scope, and timestamps. Supports filtering by siteId, state, and sorting. Use siteIds to scope to a specific site. State values: active, inactive, deactivated, draft. Requires Hyper Automate.view permission.`,
39
+ inputSchema: {
40
+ type: 'object',
41
+ properties: {
42
+ limit: {
43
+ type: 'number',
44
+ description: 'Max workflows per page (default 50, max 200).',
45
+ default: 50,
46
+ },
47
+ skip: {
48
+ type: 'number',
49
+ description: 'Offset for pagination (default 0).',
50
+ default: 0,
51
+ },
52
+ siteIds: {
53
+ type: 'string',
54
+ description: 'Comma-separated site IDs to scope results to (e.g. "<site-id-1>,<site-id-2>"). Omit for all accessible scopes.',
55
+ },
56
+ sortBy: {
57
+ type: 'string',
58
+ description: 'Sort field. Default: updated_at.',
59
+ enum: ['updated_at', 'created_at', 'name'],
60
+ default: 'updated_at',
61
+ },
62
+ sortOrder: {
63
+ type: 'string',
64
+ description: 'Sort direction.',
65
+ enum: ['asc', 'desc'],
66
+ default: 'desc',
67
+ },
68
+ },
69
+ required: [],
70
+ },
71
+ async handler({ limit = 50, skip = 0, siteIds, sortBy = 'updated_at', sortOrder = 'desc' } = {}) {
72
+ const params = {
73
+ limit: Math.min(limit, 200),
74
+ skip,
75
+ sortBy,
76
+ sortOrder,
77
+ };
78
+ if (siteIds) params.siteIds = siteIds;
79
+ const result = await apiGet(`${HA_BASE}/workflows`, params);
80
+ // Summarise for readability: include key fields the LLM needs for follow-up calls.
81
+ const items = (result?.data || []).map(item => ({
82
+ id: item.id,
83
+ revisionId: item.workflow?.version_id, // needed for ha_get_workflow
84
+ name: item.workflow?.name,
85
+ state: item.workflow?.state, // active | inactive | deactivated | draft
86
+ status: item.workflow?.status, // idle | running | etc.
87
+ scopeLevel: item.workflow?.scope_level, // account | site
88
+ scopeId: item.workflow?.scope_id,
89
+ siteName: item.workflow?.site_name,
90
+ createdAt: item.workflow?.created_at,
91
+ updatedAt: item.workflow?.updated_at,
92
+ versionCount: item.workflow?.version_count,
93
+ triggerTypes: [...new Set((item.actions || [])
94
+ .filter(a => a.type?.endsWith('_trigger'))
95
+ .map(a => a.type))],
96
+ actionTypes: [...new Set((item.actions || [])
97
+ .filter(a => !a.type?.endsWith('_trigger'))
98
+ .map(a => a.type))],
99
+ integrationIds: [...new Set((item.actions || [])
100
+ .filter(a => a.integration_id)
101
+ .map(a => a.integration_id))],
102
+ }));
103
+ return JSON.stringify({
104
+ workflows: items,
105
+ totalItems: result?.pagination?.totalItems ?? null,
106
+ skip,
107
+ limit: params.limit,
108
+ }, null, 2);
109
+ },
110
+ },
111
+
112
+ // ─── ha_get_workflow ──────────────────────────────────────────────────────
113
+ {
114
+ name: 'ha_get_workflow',
115
+ description: `Get a single Hyperautomation workflow by workflowId and revisionId. The revisionId (= workflow.version_id) is returned by ha_list_workflows; always pass both IDs for a direct fetch. If revisionId is omitted, the tool will scan the first page of workflows to find the current revision, which is slower. Returns the full workflow object including trigger configuration, action steps, integration dependencies, scope, and version metadata.`,
116
+ inputSchema: {
117
+ type: 'object',
118
+ properties: {
119
+ workflowId: {
120
+ type: 'string',
121
+ description: 'Hyperautomation workflow UUID (from ha_list_workflows).',
122
+ },
123
+ revisionId: {
124
+ type: 'string',
125
+ description: 'Workflow version/revision UUID (= workflow.version_id from ha_list_workflows). Provide this to avoid an extra list call.',
126
+ },
127
+ },
128
+ required: ['workflowId'],
129
+ },
130
+ async handler({ workflowId, revisionId }) {
131
+ let resolvedRevisionId = revisionId;
132
+
133
+ // If revisionId not supplied, resolve it from the list endpoint.
134
+ if (!resolvedRevisionId) {
135
+ // The list endpoint does not support filtering by workflowId, so we scan
136
+ // up to 200 workflows sorted by updated_at desc (most recently touched first).
137
+ // Confirmed: GET /workflows/single/{id} alone returns 404; revisionId is required.
138
+ const listResult = await apiGet(`${HA_BASE}/workflows`, {
139
+ limit: 200,
140
+ skip: 0,
141
+ sortBy: 'updated_at',
142
+ sortOrder: 'desc',
143
+ });
144
+ const found = (listResult?.data || []).find(item => item.id === workflowId);
145
+ if (!found) {
146
+ return JSON.stringify({
147
+ error: `Workflow ${workflowId} not found in the first 200 results. ` +
148
+ 'Provide revisionId directly (from ha_list_workflows) for an exact fetch.',
149
+ });
150
+ }
151
+ resolvedRevisionId = found.workflow?.version_id;
152
+ }
153
+
154
+ // Confirmed endpoint: GET /workflows/single/{workflowId}/{revisionId}
155
+ const result = await apiGet(`${HA_BASE}/workflows/single/${workflowId}/${resolvedRevisionId}`);
156
+ return JSON.stringify(result, null, 2);
157
+ },
158
+ },
159
+
160
+ // ─── ha_delete_workflow ───────────────────────────────────────────────────
161
+ {
162
+ name: 'ha_delete_workflow',
163
+ description: `Delete one or more Hyperautomation workflows. Uses the REST DELETE /hyper-automate/api/v1/workflows/{id} endpoint (validated 2026-06-13), a soft, recoverable delete (the console offers a "Restore workflow" action), equivalent to clicking Delete in the Hyperautomation UI. Scope the call to where the workflow lives with accountIds (account-scoped workflow) or siteIds (site-scoped); a 404 "Object not found" means the id is not under that scope or is already deleted. Requires Hyper Automate.write permission. NOTE: do NOT use the older POST /workflows/archive path; it returns 500 on this tenant.`,
164
+ inputSchema: {
165
+ type: 'object',
166
+ properties: {
167
+ workflowIds: {
168
+ type: 'array',
169
+ items: { type: 'string' },
170
+ description: 'One or more workflow UUIDs to delete (from ha_list_workflows).',
171
+ },
172
+ accountIds: {
173
+ type: 'string',
174
+ description: 'Account scope for an account-level workflow (e.g. "2046190533732727925"). Provide this OR siteIds.',
175
+ },
176
+ siteIds: {
177
+ type: 'string',
178
+ description: 'Site scope for a site-level workflow. Provide this OR accountIds.',
179
+ },
180
+ },
181
+ required: ['workflowIds'],
182
+ },
183
+ async handler({ workflowIds, accountIds, siteIds } = {}) {
184
+ if (!Array.isArray(workflowIds) || workflowIds.length === 0) {
185
+ return JSON.stringify({ error: 'workflowIds must be a non-empty array of UUIDs.' });
186
+ }
187
+ if (!accountIds && !siteIds) {
188
+ return JSON.stringify({ error: 'Provide accountIds or siteIds to scope the delete to where the workflow lives (a workflow created at a site must be deleted with siteIds; an account-scoped one with accountIds).' });
189
+ }
190
+ const scope = accountIds
191
+ ? `accountIds=${encodeURIComponent(accountIds)}`
192
+ : `siteIds=${encodeURIComponent(siteIds)}`;
193
+ const results = [];
194
+ for (const id of workflowIds) {
195
+ try {
196
+ // DELETE returns 204 No Content on success.
197
+ await apiDelete(`${HA_BASE}/workflows/${encodeURIComponent(id)}?${scope}`);
198
+ results.push({ id, status: 'deleted' });
199
+ } catch (e) {
200
+ results.push({ id, status: 'error', error: e.message });
201
+ }
202
+ }
203
+ return JSON.stringify({ deleted: results }, null, 2);
204
+ },
205
+ },
206
+
207
+ // ─── ha_import_workflow ───────────────────────────────────────────────────
208
+ {
209
+ name: 'ha_import_workflow',
210
+ description: `Import a Hyperautomation workflow JSON into the SentinelOne console. Scope the import with accountIds (account-level) or siteIds (site-level); on a scoped tenant a bare import with no scope returns a misleading 403 "Insufficient permissions". The workflow JSON must follow the Hyperautomation schema (use the hyperautomation skill to generate valid JSON). Integration-backed actions (type=http_request with an integration_id) require pre-configured connections in Hyperautomation > Integrations before the workflow will run, and an imported flow lands as a private draft until published (publish endpoint) or activated. Returns the created workflow ID on success. Requires Hyper Automate.write permission.`,
211
+ inputSchema: {
212
+ type: 'object',
213
+ properties: {
214
+ workflowJson: {
215
+ type: 'string',
216
+ description: 'Full Hyperautomation workflow JSON as a string. Must be valid Hyperautomation schema. Generate this using the hyperautomation skill.',
217
+ },
218
+ accountIds: {
219
+ type: 'string',
220
+ description: 'Account scope for an account-level import (e.g. "2046190533732727925"). Provide this OR siteIds.',
221
+ },
222
+ siteIds: {
223
+ type: 'string',
224
+ description: 'Site scope for a site-level import. Provide this OR accountIds.',
225
+ },
226
+ },
227
+ required: ['workflowJson'],
228
+ },
229
+ async handler({ workflowJson, accountIds, siteIds }) {
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(workflowJson);
233
+ } catch (e) {
234
+ return JSON.stringify({ error: `Invalid JSON: ${e.message}` });
235
+ }
236
+ // Public import endpoint. Append the scope query param: account-level imports use
237
+ // ?accountIds=, site-level use ?siteIds=. A bare import (no scope) returns a misleading
238
+ // 403 on a scoped tenant (validated 2026-06-13).
239
+ let qs = '';
240
+ if (accountIds) qs = `?accountIds=${encodeURIComponent(accountIds)}`;
241
+ else if (siteIds) qs = `?siteIds=${encodeURIComponent(siteIds)}`;
242
+ const result = await apiPost(`${HA_PUBLIC}/workflow-import-export/import${qs}`, { data: parsed });
243
+ return JSON.stringify(result, null, 2);
244
+ },
245
+ },
246
+
247
+ // ─── ha_export_workflow ───────────────────────────────────────────────────
248
+ {
249
+ name: 'ha_export_workflow',
250
+ description: `Export all Hyperautomation workflows as a ZIP archive. Returns metadata about the ZIP (size, content-type) plus the first 200 bytes of the base64-encoded content. NOTE: The export API (confirmed via live backtest) returns ALL workflows; there is no per-workflow filter. Use ha_get_workflow to read a specific workflow's JSON definition instead. Export/import endpoints were not captured in the v1 network trace; this tool uses the confirmed /public path.`,
251
+ inputSchema: {
252
+ type: 'object',
253
+ properties: {},
254
+ required: [],
255
+ },
256
+ async handler() {
257
+ // Export path confirmed working during backtest at /public path.
258
+ // GET returns binary ZIP of ALL workflows; POST returns 405.
259
+ // Per-workflow filter is not supported by this API version.
260
+ const { getCreds } = await import('../lib/credentials.js');
261
+ const creds = getCreds();
262
+ const base = creds.S1_CONSOLE_URL.replace(/\/+$/, '');
263
+ const tok = creds.S1_CONSOLE_API_TOKEN;
264
+ const url = `${base}${HA_PUBLIC}/workflow-import-export/export`;
265
+
266
+ const res = await fetch(url, {
267
+ method: 'GET',
268
+ headers: { Authorization: `ApiToken ${tok}` },
269
+ });
270
+ if (!res.ok) {
271
+ const text = await res.text();
272
+ throw new Error(`ha_export_workflow → ${res.status}: ${text}`);
273
+ }
274
+ const buf = await res.arrayBuffer();
275
+ const base64 = Buffer.from(buf).toString('base64');
276
+ return JSON.stringify({
277
+ note: 'Export returns all workflows as a binary ZIP. Per-workflow filtering is not supported. METADATA ONLY: this tool does not return or persist the ZIP content; use the console UI or a direct API call with file output if you need the archive itself.',
278
+ contentType: res.headers.get('Content-Type') || 'application/zip',
279
+ sizeBytes: buf.byteLength,
280
+ base64Preview: base64.slice(0, 200) + '… [truncated]',
281
+ }, null, 2);
282
+ },
283
+ },
284
+ ];