@pmoses-s1/sentinelone-mcp 1.0.0 → 1.2.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.
- package/CHANGELOG.md +60 -0
- package/README.md +422 -102
- package/deploy/README.md +366 -0
- package/deploy/bridge/README.md +93 -0
- package/deploy/bridge/sentinelone-mcp-bridge.mjs +104 -0
- package/deploy/caddy/Caddyfile.example +110 -0
- package/deploy/install.sh +264 -0
- package/deploy/systemd/sentinelone-mcp.service +58 -0
- package/index.js +135 -312
- package/lib/auth.js +161 -0
- package/lib/credentials.js +18 -7
- package/lib/hec.js +128 -0
- package/lib/http-transport.js +223 -0
- package/lib/s1.js +1 -1
- package/lib/sdl.js +0 -32
- package/lib/server-core.js +264 -0
- package/lib/stdio-transport.js +77 -0
- package/lib/uam-ingest.js +1 -1
- package/package.json +10 -4
- package/scripts/regen-readme-tools-table.mjs +142 -0
- package/scripts/smoke-test-http.sh +122 -0
- package/scripts/test-mac.sh +179 -0
- package/tools/hyperautomation.js +1 -1
- package/tools/mgmt-console.js +30 -3
- package/tools/sdl-api.js +16 -24
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server core: transport-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - dispatch(method, params, id): JSON-RPC method dispatcher
|
|
6
|
+
* - SERVER_INFO, PROTOCOL_VERSION: server identity
|
|
7
|
+
* - TOOL_DEFS: for diagnostics / introspection
|
|
8
|
+
* - ALL_TOOLS: for tests
|
|
9
|
+
*
|
|
10
|
+
* Both stdio-transport and http-transport import dispatch() and feed it
|
|
11
|
+
* parsed JSON-RPC envelopes. They are responsible for serialization,
|
|
12
|
+
* framing, and any transport-specific concerns (auth, sessions, headers).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync } from 'fs';
|
|
16
|
+
import { join, dirname } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
|
|
19
|
+
import { tools as pqTools } from '../tools/powerquery.js';
|
|
20
|
+
import { tools as mgmtTools } from '../tools/mgmt-console.js';
|
|
21
|
+
import { tools as sdlTools } from '../tools/sdl-api.js';
|
|
22
|
+
import { tools as haTools } from '../tools/hyperautomation.js';
|
|
23
|
+
import { tools as uamIngestTools } from '../tools/uam-ingest.js';
|
|
24
|
+
import { getCreds, hasS1Creds, hasSdlCreds } from './credentials.js';
|
|
25
|
+
import { hasHecCreds } from './uam-ingest.js';
|
|
26
|
+
|
|
27
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
// ─── SOC context (CLAUDE.md) ──────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function loadSocContext() {
|
|
32
|
+
const candidates = [
|
|
33
|
+
process.env.S1_CLAUDE_MD_PATH,
|
|
34
|
+
process.cwd() ? join(process.cwd(), 'CLAUDE.md') : null,
|
|
35
|
+
join(__dir, '..', '..', 'CLAUDE.md'), // claude-skills/CLAUDE.md (git clone)
|
|
36
|
+
join(__dir, '..', '..', '..', 'CLAUDE.md'),
|
|
37
|
+
join(__dir, '..', 'CLAUDE.md'),
|
|
38
|
+
].filter(Boolean);
|
|
39
|
+
for (const p of candidates) {
|
|
40
|
+
if (existsSync(p)) {
|
|
41
|
+
try { return readFileSync(p, 'utf-8'); } catch { /* skip */ }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return '# SentinelOne SOC Analyst Context\n\n_CLAUDE.md not found. Place it in your Cowork project folder, or set S1_CLAUDE_MD_PATH to an absolute path._';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SOC_CONTEXT = loadSocContext();
|
|
48
|
+
|
|
49
|
+
// ─── Tool registry ────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
export const ALL_TOOLS = [...pqTools, ...mgmtTools, ...sdlTools, ...haTools, ...uamIngestTools];
|
|
52
|
+
|
|
53
|
+
export const TOOL_DEFS = ALL_TOOLS.map(t => ({
|
|
54
|
+
name: t.name,
|
|
55
|
+
description: t.description,
|
|
56
|
+
inputSchema: t.inputSchema,
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
const HANDLERS = Object.fromEntries(ALL_TOOLS.map(t => [t.name, t.handler]));
|
|
60
|
+
|
|
61
|
+
// ─── Resources ────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
const RESOURCES = [
|
|
64
|
+
{
|
|
65
|
+
uri: 'sentinelone://soc-context',
|
|
66
|
+
name: 'SOC Analyst Operating Instructions',
|
|
67
|
+
description: 'CLAUDE.md — Principal SOC Analyst operating instructions including investigation workflow, evidence discipline, anomaly detection playbook, MITRE ATT&CK mapping, and tool usage priorities.',
|
|
68
|
+
mimeType: 'text/markdown',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
uri: 'sentinelone://credentials-status',
|
|
72
|
+
name: 'Credential Configuration Status',
|
|
73
|
+
description: 'Reports which credentials are configured and which API surfaces are available.',
|
|
74
|
+
mimeType: 'application/json',
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
// ─── Prompts ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const PROMPTS = [
|
|
81
|
+
{
|
|
82
|
+
name: 'soc_analyst',
|
|
83
|
+
description: 'Load the Principal SOC Analyst system context from CLAUDE.md. Call at the start of every security investigation session to prime the operating instructions, evidence discipline rules, investigation workflow, and tool usage priorities.',
|
|
84
|
+
arguments: [],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'session_init',
|
|
88
|
+
description: 'Structured session initialization prompt. Triggers mandatory data-source enumeration, alert triage, and schema discovery in parallel, mirroring the standard engagement workflow from the SOC playbook.',
|
|
89
|
+
arguments: [],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// ─── MCP envelope helpers ─────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export const SERVER_INFO = {
|
|
96
|
+
name: 'sentinelone-mcp-server',
|
|
97
|
+
version: '1.1.0',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const PROTOCOL_VERSION = '2024-11-05';
|
|
101
|
+
|
|
102
|
+
export function ok(id, result) {
|
|
103
|
+
return { jsonrpc: '2.0', id, result };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function err(id, code, message, data) {
|
|
107
|
+
return { jsonrpc: '2.0', id, error: { code, message, ...(data ? { data } : {}) } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function log(...args) {
|
|
111
|
+
process.stderr.write('[sentinelone-mcp] ' + args.join(' ') + '\n');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── dispatch ────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
export async function dispatch(method, params, id) {
|
|
117
|
+
switch (method) {
|
|
118
|
+
|
|
119
|
+
case 'initialize': {
|
|
120
|
+
return ok(id, {
|
|
121
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
122
|
+
capabilities: {
|
|
123
|
+
resources: { subscribe: false, listChanged: false },
|
|
124
|
+
tools: { listChanged: false },
|
|
125
|
+
prompts: { listChanged: false },
|
|
126
|
+
},
|
|
127
|
+
serverInfo: SERVER_INFO,
|
|
128
|
+
instructions: 'SentinelOne MCP server providing PowerQuery, Mgmt Console API, SDL API, and Hyperautomation tools. Load the "soc_analyst" prompt at session start for full operating context.',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case 'ping': {
|
|
133
|
+
return ok(id, {});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case 'resources/list': {
|
|
137
|
+
return ok(id, { resources: RESOURCES });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
case 'resources/read': {
|
|
141
|
+
const uri = params?.uri;
|
|
142
|
+
if (uri === 'sentinelone://soc-context') {
|
|
143
|
+
return ok(id, {
|
|
144
|
+
contents: [{ uri, mimeType: 'text/markdown', text: SOC_CONTEXT }],
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (uri === 'sentinelone://credentials-status') {
|
|
148
|
+
const c = getCreds();
|
|
149
|
+
const status = {
|
|
150
|
+
s1MgmtApi: {
|
|
151
|
+
configured: hasS1Creds(),
|
|
152
|
+
consoleUrl: c.S1_CONSOLE_URL ? c.S1_CONSOLE_URL.replace(/https?:\/\//, '').split('.')[0] + '...' : 'NOT SET',
|
|
153
|
+
tokenPresent: !!c.S1_CONSOLE_API_TOKEN,
|
|
154
|
+
},
|
|
155
|
+
sdlApi: {
|
|
156
|
+
configured: hasSdlCreds(),
|
|
157
|
+
xdrUrl: c.SDL_XDR_URL || 'NOT SET',
|
|
158
|
+
configWriteKey: !!c.SDL_CONFIG_WRITE_KEY,
|
|
159
|
+
},
|
|
160
|
+
uamIngestApi: {
|
|
161
|
+
configured: hasHecCreds(),
|
|
162
|
+
hecUrl: c.S1_HEC_INGEST_URL || 'NOT SET (add S1_HEC_INGEST_URL to credentials.json)',
|
|
163
|
+
tokenPresent: !!c.S1_CONSOLE_API_TOKEN,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
return ok(id, {
|
|
167
|
+
contents: [{ uri, mimeType: 'application/json', text: JSON.stringify(status, null, 2) }],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return err(id, -32002, `Resource not found: ${uri}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case 'prompts/list': {
|
|
174
|
+
return ok(id, { prompts: PROMPTS });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
case 'prompts/get': {
|
|
178
|
+
const name = params?.name;
|
|
179
|
+
|
|
180
|
+
if (name === 'soc_analyst') {
|
|
181
|
+
return ok(id, {
|
|
182
|
+
description: 'Principal SOC Analyst operating instructions from CLAUDE.md',
|
|
183
|
+
messages: [
|
|
184
|
+
{
|
|
185
|
+
role: 'user',
|
|
186
|
+
content: {
|
|
187
|
+
type: 'text',
|
|
188
|
+
text: `You are operating as a Principal SOC Analyst. Load and follow the instructions below precisely.\n\n${SOC_CONTEXT}`,
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (name === 'session_init') {
|
|
196
|
+
return ok(id, {
|
|
197
|
+
description: 'Structured session initialization',
|
|
198
|
+
messages: [
|
|
199
|
+
{
|
|
200
|
+
role: 'user',
|
|
201
|
+
content: {
|
|
202
|
+
type: 'text',
|
|
203
|
+
text: `Begin a new SOC analyst session. Follow this initialization sequence:
|
|
204
|
+
|
|
205
|
+
1. Call \`powerquery_enumerate_sources\` to discover active SDL data sources (MANDATORY, never assume sources from prior sessions).
|
|
206
|
+
2. In parallel, call \`uam_list_alerts\` with filter="status=OPEN" to pull active alerts.
|
|
207
|
+
3. For each discovered data source not already in the schema registry, plan schema discovery via \`powerquery_schema_discover\`.
|
|
208
|
+
4. Report: (a) active data sources list, (b) open alert count and top 5 by severity, (c) which sources need schema discovery.
|
|
209
|
+
|
|
210
|
+
Apply the SOC analyst context from the soc_analyst prompt throughout.`,
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return err(id, -32002, `Prompt not found: ${name}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case 'tools/list': {
|
|
221
|
+
return ok(id, { tools: TOOL_DEFS });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case 'tools/call': {
|
|
225
|
+
const toolName = params?.name;
|
|
226
|
+
const args = params?.arguments || {};
|
|
227
|
+
|
|
228
|
+
if (!toolName) {
|
|
229
|
+
return err(id, -32602, 'Missing tool name');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const handler = HANDLERS[toolName];
|
|
233
|
+
if (!handler) {
|
|
234
|
+
return err(id, -32602, `Tool not found: ${toolName}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
const output = await handler(args);
|
|
239
|
+
const text = typeof output === 'string' ? output : JSON.stringify(output, null, 2);
|
|
240
|
+
return ok(id, {
|
|
241
|
+
content: [{ type: 'text', text }],
|
|
242
|
+
isError: false,
|
|
243
|
+
});
|
|
244
|
+
} catch (e) {
|
|
245
|
+
log(`Tool error [${toolName}]:`, e.message);
|
|
246
|
+
return ok(id, {
|
|
247
|
+
content: [{ type: 'text', text: `Error: ${e.message}` }],
|
|
248
|
+
isError: true,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
case 'notifications/initialized':
|
|
254
|
+
case 'initialized':
|
|
255
|
+
return null;
|
|
256
|
+
|
|
257
|
+
default: {
|
|
258
|
+
if (id !== undefined) {
|
|
259
|
+
return err(id, -32601, `Method not found: ${method}`);
|
|
260
|
+
}
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stdio transport: reads JSON-RPC messages from stdin (one per line),
|
|
3
|
+
* dispatches via the provided dispatcher, writes responses to stdout.
|
|
4
|
+
*
|
|
5
|
+
* This is the default transport and the one used by Claude Desktop,
|
|
6
|
+
* Claude Code, Claude Cowork, and any other client launched via the
|
|
7
|
+
* `npx` / `node index.js` invocation pattern.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createInterface } from 'readline';
|
|
11
|
+
import { err as makeErr } from './server-core.js';
|
|
12
|
+
|
|
13
|
+
function log(...args) {
|
|
14
|
+
process.stderr.write('[sentinelone-mcp] ' + args.join(' ') + '\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function send(obj) {
|
|
18
|
+
process.stdout.write(JSON.stringify(obj) + '\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function startStdio(dispatch) {
|
|
22
|
+
log('Transport: stdio');
|
|
23
|
+
|
|
24
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
25
|
+
|
|
26
|
+
let inFlight = 0;
|
|
27
|
+
let stdinClosed = false;
|
|
28
|
+
|
|
29
|
+
function maybeExit() {
|
|
30
|
+
if (stdinClosed && inFlight === 0) {
|
|
31
|
+
log('All requests complete, exiting.');
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
rl.on('line', async (line) => {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed) return;
|
|
39
|
+
|
|
40
|
+
let msg;
|
|
41
|
+
try {
|
|
42
|
+
msg = JSON.parse(trimmed);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
send(makeErr(null, -32700, `Parse error: ${e.message}`));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const isNotification = msg.id === undefined;
|
|
49
|
+
|
|
50
|
+
inFlight++;
|
|
51
|
+
try {
|
|
52
|
+
const response = await dispatch(msg.method, msg.params, msg.id);
|
|
53
|
+
if (response !== null && !isNotification) {
|
|
54
|
+
send(response);
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
log('Unhandled dispatch error:', e.message, e.stack);
|
|
58
|
+
if (!isNotification) {
|
|
59
|
+
send(makeErr(msg.id ?? null, -32603, `Internal error: ${e.message}`));
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
inFlight--;
|
|
63
|
+
maybeExit();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
rl.on('close', () => {
|
|
68
|
+
log('stdin closed, waiting for in-flight requests...');
|
|
69
|
+
stdinClosed = true;
|
|
70
|
+
maybeExit();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
process.on('SIGINT', () => {
|
|
74
|
+
log('SIGINT received, exiting.');
|
|
75
|
+
process.exit(0);
|
|
76
|
+
});
|
|
77
|
+
}
|
package/lib/uam-ingest.js
CHANGED
|
@@ -112,7 +112,7 @@ async function hecPost(path, payloads, scope, retries = 3) {
|
|
|
112
112
|
*
|
|
113
113
|
* Shape matches the confirmed-working Python build_file_indicator() in
|
|
114
114
|
* sentinelone-mgmt-console-api/scripts/uam_alert_interface.py (tested
|
|
115
|
-
* on usea1-
|
|
115
|
+
* on usea1-acme 2026-04-22). Key points:
|
|
116
116
|
* - metadata.version "1.6.0-dev" (not "1.6.0")
|
|
117
117
|
* - metadata.extensions array (not "extension" singular)
|
|
118
118
|
* - metadata.product omits vendor_name (just name)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmoses-s1/sentinelone-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP server orchestrating SentinelOne skills, APIs, and SOC analyst context",
|
|
3
|
+
"version": "1.2.0",
|
|
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",
|
|
7
7
|
"bin": {
|
|
@@ -11,11 +11,17 @@
|
|
|
11
11
|
"index.js",
|
|
12
12
|
"lib/",
|
|
13
13
|
"tools/",
|
|
14
|
-
"
|
|
14
|
+
"deploy/",
|
|
15
|
+
"scripts/",
|
|
16
|
+
"README.md",
|
|
17
|
+
"CHANGELOG.md"
|
|
15
18
|
],
|
|
16
19
|
"scripts": {
|
|
17
20
|
"start": "node index.js",
|
|
18
|
-
"
|
|
21
|
+
"start:http": "node index.js --transport http",
|
|
22
|
+
"dev": "node --watch index.js",
|
|
23
|
+
"test": "node --test tests/smoke.test.mjs tests/stdio-transport.test.mjs tests/http-transport.test.mjs",
|
|
24
|
+
"regen:readme": "node scripts/regen-readme-tools-table.mjs"
|
|
19
25
|
},
|
|
20
26
|
"engines": {
|
|
21
27
|
"node": ">=18.0.0"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Regenerate the "What this exposes" tools table in sentinelone-mcp/README.md
|
|
4
|
+
* directly from the live ALL_TOOLS array in server-core.js.
|
|
5
|
+
*
|
|
6
|
+
* This is the guard against the drift that produced the original
|
|
7
|
+
* 19-vs-21-vs-26 confusion: if the table doesn't match the registered
|
|
8
|
+
* tools, the build fails (when run via `npm run regen:readme -- --check`).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/regen-readme-tools-table.mjs Rewrite README in place.
|
|
12
|
+
* node scripts/regen-readme-tools-table.mjs --check Exit 1 if table is stale.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, resolve } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { ALL_TOOLS } from '../lib/server-core.js';
|
|
19
|
+
|
|
20
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const README = resolve(__dir, '..', 'README.md');
|
|
22
|
+
|
|
23
|
+
// Map each tool to its origin module + originating skill name(s).
|
|
24
|
+
// This is the only hand-maintained mapping; updating it is part of adding a
|
|
25
|
+
// new tool's row to the README.
|
|
26
|
+
const TOOL_SKILL = {
|
|
27
|
+
// PowerQuery
|
|
28
|
+
powerquery_enumerate_sources: 'sentinelone-powerquery',
|
|
29
|
+
powerquery_run: 'sentinelone-powerquery',
|
|
30
|
+
powerquery_schema_discover: 'sentinelone-powerquery',
|
|
31
|
+
// Mgmt Console
|
|
32
|
+
s1_api_get: 'sentinelone-mgmt-console-api',
|
|
33
|
+
s1_api_post: 'sentinelone-mgmt-console-api',
|
|
34
|
+
s1_api_put: 'sentinelone-mgmt-console-api',
|
|
35
|
+
s1_api_delete: 'sentinelone-mgmt-console-api',
|
|
36
|
+
s1_api_patch: 'sentinelone-mgmt-console-api',
|
|
37
|
+
purple_ai_alert_summary: 'sentinelone-mgmt-console-api',
|
|
38
|
+
uam_list_alerts: 'sentinelone-mgmt-console-api',
|
|
39
|
+
uam_get_alert: 'sentinelone-mgmt-console-api',
|
|
40
|
+
uam_add_note: 'sentinelone-mgmt-console-api',
|
|
41
|
+
uam_set_status: 'sentinelone-mgmt-console-api',
|
|
42
|
+
// SDL API
|
|
43
|
+
sdl_list_files: 'sentinelone-sdl-api / sdl-dashboard / sdl-log-parser',
|
|
44
|
+
sdl_get_file: 'sentinelone-sdl-api / sdl-dashboard / sdl-log-parser',
|
|
45
|
+
sdl_put_file: 'sentinelone-sdl-api / sdl-dashboard / sdl-log-parser',
|
|
46
|
+
sdl_delete_file: 'sentinelone-sdl-api',
|
|
47
|
+
hec_ingest: 'sentinelone-sdl-api / sdl-log-parser',
|
|
48
|
+
// Hyperautomation
|
|
49
|
+
ha_list_workflows: 'sentinelone-hyperautomation',
|
|
50
|
+
ha_get_workflow: 'sentinelone-hyperautomation',
|
|
51
|
+
ha_archive_workflow: 'sentinelone-hyperautomation',
|
|
52
|
+
ha_import_workflow: 'sentinelone-hyperautomation',
|
|
53
|
+
ha_export_workflow: 'sentinelone-hyperautomation',
|
|
54
|
+
// UAM Ingest
|
|
55
|
+
uam_ingest_alert: 'sentinelone-mgmt-console-api (UAM Alert Interface)',
|
|
56
|
+
uam_post_indicators: 'sentinelone-mgmt-console-api (UAM Alert Interface)',
|
|
57
|
+
uam_post_alert: 'sentinelone-mgmt-console-api (UAM Alert Interface)',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const GROUPS = [
|
|
61
|
+
{ label: 'PowerQuery', prefix: 'powerquery_' },
|
|
62
|
+
{ label: 'Mgmt Console', test: n => /^(s1_api_|purple_ai_|uam_(list|get|add|set))/.test(n) },
|
|
63
|
+
{ label: 'SDL API', prefix: 'sdl_' },
|
|
64
|
+
{ label: 'Hyperautomation', prefix: 'ha_' },
|
|
65
|
+
{ label: 'UAM Ingest', test: n => /^(uam_ingest_|uam_post_)/.test(n) },
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function groupOf(name) {
|
|
69
|
+
for (const g of GROUPS) {
|
|
70
|
+
if (g.prefix && name.startsWith(g.prefix)) return g.label;
|
|
71
|
+
if (g.test && g.test(name)) return g.label;
|
|
72
|
+
}
|
|
73
|
+
return '???';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Build the new table block. The leading "**N tools**" header is regenerated
|
|
77
|
+
// too so the count and the table can't drift apart.
|
|
78
|
+
function buildTable() {
|
|
79
|
+
const sorted = [...ALL_TOOLS]
|
|
80
|
+
.map(t => t.name)
|
|
81
|
+
.sort((a, b) => {
|
|
82
|
+
const ga = GROUPS.findIndex(g => groupOf(a) === g.label);
|
|
83
|
+
const gb = GROUPS.findIndex(g => groupOf(b) === g.label);
|
|
84
|
+
if (ga !== gb) return ga - gb;
|
|
85
|
+
return a.localeCompare(b);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const lines = [];
|
|
89
|
+
lines.push(`**${ALL_TOOLS.length} tools** across PowerQuery, Mgmt Console, SDL API, Hyperautomation, and UAM Ingest:`);
|
|
90
|
+
lines.push('');
|
|
91
|
+
lines.push('| Group | Tool | Skill |');
|
|
92
|
+
lines.push('|-------|------|-------|');
|
|
93
|
+
for (const name of sorted) {
|
|
94
|
+
const group = groupOf(name);
|
|
95
|
+
const skill = TOOL_SKILL[name] || '';
|
|
96
|
+
if (!skill) {
|
|
97
|
+
throw new Error(`Missing TOOL_SKILL mapping for "${name}". Update scripts/regen-readme-tools-table.mjs.`);
|
|
98
|
+
}
|
|
99
|
+
lines.push(`| ${group} | \`${name}\` | ${skill} |`);
|
|
100
|
+
}
|
|
101
|
+
return lines.join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const START = '<!-- BEGIN AUTO-GENERATED TOOLS TABLE -->';
|
|
105
|
+
const END = '<!-- END AUTO-GENERATED TOOLS TABLE -->';
|
|
106
|
+
|
|
107
|
+
function spliceTable(readme, table) {
|
|
108
|
+
const start = readme.indexOf(START);
|
|
109
|
+
const end = readme.indexOf(END);
|
|
110
|
+
if (start < 0 || end < 0 || end < start) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`README is missing the BEGIN/END auto-generated markers:\n ${START}\n ${END}\n` +
|
|
113
|
+
`Add both to README.md around the tools table block before running this script.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const head = readme.slice(0, start + START.length);
|
|
117
|
+
const tail = readme.slice(end);
|
|
118
|
+
return head + '\n' + table + '\n' + tail;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const args = process.argv.slice(2);
|
|
122
|
+
const checkOnly = args.includes('--check');
|
|
123
|
+
|
|
124
|
+
const before = readFileSync(README, 'utf-8');
|
|
125
|
+
const table = buildTable();
|
|
126
|
+
const after = spliceTable(before, table);
|
|
127
|
+
|
|
128
|
+
if (checkOnly) {
|
|
129
|
+
if (before !== after) {
|
|
130
|
+
process.stderr.write('README tools table is out of sync with ALL_TOOLS.\n');
|
|
131
|
+
process.stderr.write('Run `npm run regen:readme` to fix.\n');
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
process.stdout.write('README tools table is in sync.\n');
|
|
135
|
+
} else {
|
|
136
|
+
if (before === after) {
|
|
137
|
+
process.stdout.write('No changes; README tools table already in sync.\n');
|
|
138
|
+
} else {
|
|
139
|
+
writeFileSync(README, after);
|
|
140
|
+
process.stdout.write(`Updated README tools table (${ALL_TOOLS.length} tools).\n`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# smoke-test-http.sh -- generic HTTP smoke test for sentinelone-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 sentinelone-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() { echo " FAIL: $*" >&2; FAILED=$((FAILED+1)); }
|
|
51
|
+
pass() { echo " PASS: $*"; }
|
|
52
|
+
|
|
53
|
+
echo "=== 1. healthz (no auth) ==="
|
|
54
|
+
HEALTHZ_CODE=$(curl -s -o /dev/null -w "%{http_code}" "https://$HOST/healthz")
|
|
55
|
+
[[ "$HEALTHZ_CODE" == "200" ]] && pass "healthz returned 200" || fail "healthz returned $HEALTHZ_CODE (expected 200)"
|
|
56
|
+
|
|
57
|
+
echo
|
|
58
|
+
echo "=== 2. initialize ==="
|
|
59
|
+
INIT_BODY=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" -d '{
|
|
60
|
+
"jsonrpc": "2.0",
|
|
61
|
+
"id": 1,
|
|
62
|
+
"method": "initialize",
|
|
63
|
+
"params": {
|
|
64
|
+
"protocolVersion": "2024-11-05",
|
|
65
|
+
"capabilities": {},
|
|
66
|
+
"clientInfo": { "name": "smoke-test", "version": "1" }
|
|
67
|
+
}
|
|
68
|
+
}')
|
|
69
|
+
PROTO=$(echo "$INIT_BODY" | jq -r '.result.protocolVersion // "missing"')
|
|
70
|
+
NAME=$( echo "$INIT_BODY" | jq -r '.result.serverInfo.name // "missing"')
|
|
71
|
+
VER=$( echo "$INIT_BODY" | jq -r '.result.serverInfo.version // "missing"')
|
|
72
|
+
[[ "$PROTO" == "2024-11-05" ]] && pass "protocolVersion=$PROTO" || fail "protocolVersion=$PROTO"
|
|
73
|
+
[[ "$NAME" == "sentinelone-mcp-server" ]] && pass "serverInfo.name=$NAME" || fail "serverInfo.name=$NAME"
|
|
74
|
+
[[ "$VER" != "missing" ]] && pass "serverInfo.version=$VER" || fail "serverInfo.version missing"
|
|
75
|
+
|
|
76
|
+
echo
|
|
77
|
+
echo "=== 3. tools/list count ==="
|
|
78
|
+
TOOLS_COUNT=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
|
|
79
|
+
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
|
80
|
+
| jq '.result.tools | length')
|
|
81
|
+
[[ "$TOOLS_COUNT" == "26" ]] && pass "tools/list returned 26 tools" || fail "tools/list returned $TOOLS_COUNT"
|
|
82
|
+
|
|
83
|
+
echo
|
|
84
|
+
echo "=== 4. tools/call s1_api_get on /agents/count ==="
|
|
85
|
+
AGENTS_TOTAL=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" -d '{
|
|
86
|
+
"jsonrpc": "2.0",
|
|
87
|
+
"id": 3,
|
|
88
|
+
"method": "tools/call",
|
|
89
|
+
"params": {
|
|
90
|
+
"name": "s1_api_get",
|
|
91
|
+
"arguments": { "path": "/web/api/v2.1/agents/count" }
|
|
92
|
+
}
|
|
93
|
+
}' | jq -r '.result.content[0].text' | jq -r '.data.total // "missing"')
|
|
94
|
+
if [[ "$AGENTS_TOTAL" =~ ^[0-9]+$ ]]; then
|
|
95
|
+
pass "s1_api_get returned $AGENTS_TOTAL agents"
|
|
96
|
+
else
|
|
97
|
+
fail "s1_api_get returned data.total=$AGENTS_TOTAL"
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
echo
|
|
101
|
+
echo "=== 5. bad bearer (expect HTTP 401) ==="
|
|
102
|
+
BAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$URL" \
|
|
103
|
+
-H "Authorization: Bearer wrong-token-of-sufficient-length-1234567890" -H "$JSON" \
|
|
104
|
+
-d '{"jsonrpc":"2.0","id":99,"method":"tools/list"}')
|
|
105
|
+
[[ "$BAD_CODE" == "401" ]] && pass "bad bearer rejected with 401" || fail "bad bearer returned $BAD_CODE (expected 401)"
|
|
106
|
+
|
|
107
|
+
echo
|
|
108
|
+
echo "=== 6. method not found (expect -32601) ==="
|
|
109
|
+
ERR_CODE=$(curl -s -X POST "$URL" -H "$AUTH" -H "$JSON" \
|
|
110
|
+
-d '{"jsonrpc":"2.0","id":4,"method":"does/not/exist"}' \
|
|
111
|
+
| jq -r '.error.code // "missing"')
|
|
112
|
+
[[ "$ERR_CODE" == "-32601" ]] && pass "unknown method returned -32601" || fail "unknown method returned code=$ERR_CODE"
|
|
113
|
+
|
|
114
|
+
echo
|
|
115
|
+
echo "=== Summary ==="
|
|
116
|
+
if [[ "$FAILED" -eq 0 ]]; then
|
|
117
|
+
echo "All checks passed."
|
|
118
|
+
exit 0
|
|
119
|
+
else
|
|
120
|
+
echo "$FAILED check(s) failed."
|
|
121
|
+
exit 1
|
|
122
|
+
fi
|