@pmoses-s1/s1-secops-mcp 1.3.4 → 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 CHANGED
@@ -1,5 +1,45 @@
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
+
3
43
  ## 1.3.4 - 2026-08-17
4
44
 
5
45
  Adds site-level dashboard lifecycle. Two gaps closed: SDL GraphQL calls never
package/README.md CHANGED
@@ -73,7 +73,7 @@ Add this to `claude_desktop_config.json` (or `.mcp.json` for Claude Code):
73
73
  "mcpServers": {
74
74
  "s1-secops-mcp": {
75
75
  "command": "npx",
76
- "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.4"],
76
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.5"],
77
77
  "env": {
78
78
  "S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
79
79
  "S1_CONSOLE_API_TOKEN": "eyJ...",
package/deploy/README.md CHANGED
@@ -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.4"]
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.4` + `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`.
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/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
@@ -62,8 +62,10 @@ function resolveScope(scope) {
62
62
  return trimmed;
63
63
  }
64
64
 
65
- /** Header object carrying S1-Scope, or empty when the request is unscoped. */
66
- function scopeHeaders(scope) {
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) {
67
69
  const resolved = resolveScope(scope);
68
70
  return resolved ? { 'S1-Scope': resolved } : {};
69
71
  }
@@ -94,7 +94,7 @@ const PROMPTS = [
94
94
 
95
95
  export const SERVER_INFO = {
96
96
  name: 's1-secops-mcp-server',
97
- version: '1.3.4',
97
+ version: '1.3.5',
98
98
  };
99
99
 
100
100
  export const PROTOCOL_VERSION = '2024-11-05';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmoses-s1/s1-secops-mcp",
3
- "version": "1.3.4",
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",
File without changes
@@ -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) {