@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
package/deploy/README.md
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
# Deployment guide
|
|
2
|
+
|
|
3
|
+
Three supported topologies, in order of complexity.
|
|
4
|
+
|
|
5
|
+
| Topology | Who runs it | Transport | Auth | Use this when |
|
|
6
|
+
|---|---|---|---|---|
|
|
7
|
+
| **A. Single user, local** | One human | stdio | none | You use Claude Desktop / Claude Code / Claude Cowork on your own Mac or Linux laptop. |
|
|
8
|
+
| **B. Single user, HTTP** | One human | Streamable HTTP, `127.0.0.1` only | none | You want one server you can curl, or have a non-Claude client that speaks Streamable HTTP. |
|
|
9
|
+
| **C. Team, VM-hosted** | Many humans | Streamable HTTP, behind TLS | per-user bearer tokens | You want N team members to share one server with one set of SentinelOne credentials, with per-user audit and revocation. |
|
|
10
|
+
|
|
11
|
+
## A. Single user, local (stdio)
|
|
12
|
+
|
|
13
|
+
`curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/sentinelone-mcp/deploy/install.sh | bash`
|
|
14
|
+
|
|
15
|
+
That runs `install.sh --user`, which:
|
|
16
|
+
1. Confirms Node 18+ is present (errors out with install hints if not).
|
|
17
|
+
2. Sets up a per-user npm prefix at `~/.npm-global` if one isn't configured.
|
|
18
|
+
3. Installs `@pmoses-s1/sentinelone-mcp` globally for your user.
|
|
19
|
+
4. Writes a credentials skeleton to `~/.config/sentinelone/credentials.json` (mode 0600).
|
|
20
|
+
5. Prints the next steps.
|
|
21
|
+
|
|
22
|
+
Then edit `~/.config/sentinelone/credentials.json` with your real values:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
|
|
27
|
+
"S1_CONSOLE_API_TOKEN": "eyJ...",
|
|
28
|
+
"S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net",
|
|
29
|
+
"SDL_XDR_URL": "https://xdr.us1.sentinelone.net",
|
|
30
|
+
"SDL_LOG_READ_KEY": "...",
|
|
31
|
+
"SDL_CONFIG_READ_KEY": "...",
|
|
32
|
+
"SDL_CONFIG_WRITE_KEY": "..."
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Add the server to Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` on Mac, or `%APPDATA%\Claude\claude_desktop_config.json` on Windows):
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"sentinelone-mcp": {
|
|
42
|
+
"command": "sentinelone-mcp"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Or, equivalently, by package name without the install:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"sentinelone-mcp": {
|
|
54
|
+
"command": "npx",
|
|
55
|
+
"args": ["-y", "@pmoses-s1/sentinelone-mcp@1.1.0"]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Restart Claude Desktop. The server picks credentials up from `~/.config/sentinelone/credentials.json` automatically.
|
|
62
|
+
|
|
63
|
+
## B. Single user, HTTP
|
|
64
|
+
|
|
65
|
+
Same `install.sh --user`, then start the server in HTTP mode:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
sentinelone-mcp --transport http
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
It binds to `127.0.0.1:8765` and runs with no auth (which is fine when the bind address is loopback and you're the only user on the box). Hit it with curl:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
curl -s http://127.0.0.1:8765/healthz
|
|
75
|
+
# -> ok
|
|
76
|
+
|
|
77
|
+
curl -s -X POST http://127.0.0.1:8765/mcp \
|
|
78
|
+
-H 'Content-Type: application/json' \
|
|
79
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools | length'
|
|
80
|
+
# -> 26
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
In Claude Cowork or any MCP client that supports remote HTTP servers, add it:
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"mcpServers": {
|
|
88
|
+
"sentinelone-mcp": {
|
|
89
|
+
"type": "http",
|
|
90
|
+
"url": "http://127.0.0.1:8765/mcp"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## C. Team, VM-hosted (recommended for shared deployments)
|
|
97
|
+
|
|
98
|
+
This is the topology to use when more than one person should have access to the same SentinelOne tenant through MCP, without distributing the underlying S1 service-user token.
|
|
99
|
+
|
|
100
|
+
### What you'll end up with
|
|
101
|
+
|
|
102
|
+
- One Linux VM, reachable on your private network (or via Tailscale, WireGuard, etc.).
|
|
103
|
+
- One `mcp` system user owning `/etc/sentinelone-mcp/`.
|
|
104
|
+
- One `credentials.json` containing the S1 service-user token + SDL keys. Mode 0600, never copied off the box.
|
|
105
|
+
- One `bearer-tokens.json` listing per-user tokens, one per team member: `{"alice": "...", "bob": "...", "claire": "..."}`. Mode 0600. SIGHUP-reloadable.
|
|
106
|
+
- One systemd service running the MCP on `127.0.0.1:8765` with auth enforced.
|
|
107
|
+
- Caddy in front terminating TLS and forwarding to the backend.
|
|
108
|
+
|
|
109
|
+
Team members connect from their Claude clients with their own bearer token. Audit log identifies them by name. Revocation is one file edit + `systemctl reload`.
|
|
110
|
+
|
|
111
|
+
### Step-by-step
|
|
112
|
+
|
|
113
|
+
1. **Provision the VM.** Anything that runs systemd is fine: Ubuntu 22.04 LTS, Debian 12, Rocky/Alma 9, etc.
|
|
114
|
+
|
|
115
|
+
2. **Install Node 18+.** Pick one:
|
|
116
|
+
```bash
|
|
117
|
+
# Ubuntu / Debian
|
|
118
|
+
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
|
119
|
+
sudo apt install -y nodejs
|
|
120
|
+
```
|
|
121
|
+
```bash
|
|
122
|
+
# Rocky / Alma
|
|
123
|
+
curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
|
|
124
|
+
sudo dnf install -y nodejs
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
3. **Run the installer in server mode:**
|
|
128
|
+
```bash
|
|
129
|
+
curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/sentinelone-mcp/deploy/install.sh | sudo bash -s -- --server
|
|
130
|
+
```
|
|
131
|
+
It creates the `mcp` user, drops `/etc/sentinelone-mcp/credentials.json` (placeholder) and `/etc/sentinelone-mcp/bearer-tokens.json` (one freshly-generated admin token, printed once to stdout), installs the systemd unit, and starts the service.
|
|
132
|
+
|
|
133
|
+
4. **Fill in real SentinelOne credentials:**
|
|
134
|
+
```bash
|
|
135
|
+
sudo vim /etc/sentinelone-mcp/credentials.json
|
|
136
|
+
sudo systemctl reload sentinelone-mcp
|
|
137
|
+
curl -s http://127.0.0.1:8765/healthz # -> ok
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
5. **Put TLS in front with Caddy** (the recommended option):
|
|
141
|
+
```bash
|
|
142
|
+
sudo apt install -y caddy
|
|
143
|
+
sudo cp /usr/lib/node_modules/@pmoses-s1/sentinelone-mcp/deploy/caddy/Caddyfile.example /etc/caddy/Caddyfile
|
|
144
|
+
sudo vim /etc/caddy/Caddyfile # change mcp.s1.internal to your DNS name
|
|
145
|
+
sudo systemctl reload caddy
|
|
146
|
+
```
|
|
147
|
+
Default Caddyfile uses `tls internal` which signs with Caddy's own CA. Distribute `/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt` to your team for trust, or use `tls <your-email>` with a publicly resolvable hostname for Let's Encrypt.
|
|
148
|
+
|
|
149
|
+
6. **Add team members.** Generate a token per person and append to the file:
|
|
150
|
+
```bash
|
|
151
|
+
sudo bash -c 'cat > /etc/sentinelone-mcp/bearer-tokens.json' <<EOF
|
|
152
|
+
{
|
|
153
|
+
"admin": "$(openssl rand -hex 32)",
|
|
154
|
+
"alice": "$(openssl rand -hex 32)",
|
|
155
|
+
"bob": "$(openssl rand -hex 32)",
|
|
156
|
+
"claire":"$(openssl rand -hex 32)"
|
|
157
|
+
}
|
|
158
|
+
EOF
|
|
159
|
+
sudo chmod 600 /etc/sentinelone-mcp/bearer-tokens.json
|
|
160
|
+
sudo chown mcp:mcp /etc/sentinelone-mcp/bearer-tokens.json
|
|
161
|
+
sudo systemctl reload sentinelone-mcp # SIGHUP, no downtime
|
|
162
|
+
```
|
|
163
|
+
Hand each person their token over a secure channel (1Password, Signal, etc.).
|
|
164
|
+
|
|
165
|
+
7. **Connect from a Claude client.** Each user adds the server to their config with their personal token:
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"mcpServers": {
|
|
169
|
+
"sentinelone-mcp": {
|
|
170
|
+
"type": "http",
|
|
171
|
+
"url": "https://mcp.s1.internal/mcp",
|
|
172
|
+
"headers": {
|
|
173
|
+
"Authorization": "Bearer <THEIR_PERSONAL_TOKEN>"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
8. **Verify end-to-end.** From a team member's machine:
|
|
181
|
+
```bash
|
|
182
|
+
curl -s -X POST https://mcp.s1.internal/mcp \
|
|
183
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
184
|
+
-H 'Content-Type: application/json' \
|
|
185
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools | length'
|
|
186
|
+
# -> 26
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
9. **Watch the audit log.** Every authenticated request is logged with the bearer name, method, and param summary:
|
|
190
|
+
```bash
|
|
191
|
+
sudo journalctl -u sentinelone-mcp -f | grep '\[audit\]'
|
|
192
|
+
# [audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
|
|
193
|
+
# [audit] 2026-05-28T15:01:34.221Z | bob | tools/list | - | 200 ok
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Day-2 operations
|
|
197
|
+
|
|
198
|
+
### Adding a team member
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
sudo vim /etc/sentinelone-mcp/bearer-tokens.json # add new {"name": "token"}
|
|
202
|
+
sudo systemctl reload sentinelone-mcp # SIGHUP, no downtime
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Revoking access
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
sudo vim /etc/sentinelone-mcp/bearer-tokens.json # remove the entry
|
|
209
|
+
sudo systemctl reload sentinelone-mcp
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Rotating the SentinelOne service-user token
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
sudo vim /etc/sentinelone-mcp/credentials.json # paste new S1_CONSOLE_API_TOKEN
|
|
216
|
+
sudo systemctl restart sentinelone-mcp # full restart needed for creds
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Upgrading the MCP server
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
sudo npm install -g @pmoses-s1/sentinelone-mcp@<new-version>
|
|
223
|
+
sudo systemctl restart sentinelone-mcp
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Reading the audit log
|
|
227
|
+
|
|
228
|
+
The structured audit lines look like:
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
[audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
|
|
232
|
+
[audit] 2026-05-28T16:42:55.108Z | bob | tools/list | - | 200 ok
|
|
233
|
+
[audit] 2026-05-28T17:03:11.221Z | - | - | - | 401 unauthorized
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
Quick filters:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
# everything alice did in the last hour
|
|
240
|
+
sudo journalctl -u sentinelone-mcp --since="1 hour ago" | grep '\[audit\].*| alice |'
|
|
241
|
+
|
|
242
|
+
# all unauthorized attempts today
|
|
243
|
+
sudo journalctl -u sentinelone-mcp --since=today | grep '\[audit\].*401'
|
|
244
|
+
|
|
245
|
+
# all tool calls (not just listings)
|
|
246
|
+
sudo journalctl -u sentinelone-mcp -f | grep 'tools/call'
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Health and readiness
|
|
250
|
+
|
|
251
|
+
`GET /healthz` returns `200 ok` whenever the server is accepting connections. Use it for load balancer probes and for `systemctl-aware` orchestrators:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
curl -s http://127.0.0.1:8765/healthz # behind the proxy
|
|
255
|
+
curl -s https://mcp.s1.internal/healthz # in front of the proxy
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Connecting Claude Desktop to a remote MCP
|
|
259
|
+
|
|
260
|
+
Claude Desktop's `claude_desktop_config.json` only accepts stdio-based MCP servers in current stable builds; the `type: "http"` form gets rejected with "not valid MCP server configuration" on load. To connect Claude Desktop to your VM's HTTPS endpoint, use the bridge script shipped in this repo at [`bridge/sentinelone-mcp-bridge.mjs`](./bridge/sentinelone-mcp-bridge.mjs) — a 40-line zero-dependency Node script that translates Claude Desktop's stdio into POST requests against the MCP HTTP endpoint.
|
|
261
|
+
|
|
262
|
+
Each team member drops the script anywhere on their machine (typically `~/.local/bin/sentinelone-mcp-bridge.mjs`) and points Claude Desktop at it:
|
|
263
|
+
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"mcpServers": {
|
|
267
|
+
"sentinelone-mcp": {
|
|
268
|
+
"command": "node",
|
|
269
|
+
"args": ["/Users/<you>/.local/bin/sentinelone-mcp-bridge.mjs"],
|
|
270
|
+
"env": {
|
|
271
|
+
"MCP_URL": "https://mcp.s1.internal/mcp",
|
|
272
|
+
"MCP_BEARER": "<your personal bearer token>"
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Then Cmd+Q and reopen Claude Desktop. See [`bridge/README.md`](./bridge/README.md) for install, smoke-test, and troubleshooting steps. Claude Cowork users can keep using the native `type: "http"` config (it supports remote HTTP MCPs in current builds) — only Claude Desktop needs the bridge.
|
|
280
|
+
|
|
281
|
+
## AWS-specific gotchas
|
|
282
|
+
|
|
283
|
+
Five things that bit during real deployment to an EC2 instance. None are blockers, but knowing them up front saves hours.
|
|
284
|
+
|
|
285
|
+
### EC2 public DNS is unstable without an Elastic IP
|
|
286
|
+
|
|
287
|
+
Stopping and starting an instance assigns a new public IPv4 address and a new public DNS name (`ec2-<new-ip>.<region>.compute.amazonaws.com`). Every ACME-issued cert, every `claude_desktop_config.json`, and every Caddyfile that referenced the old hostname breaks. Allocate an Elastic IP in EC2 → Elastic IPs → Allocate → Associate before issuing certs. Free while attached to a running instance.
|
|
288
|
+
|
|
289
|
+
The instance's `*.compute.internal` DNS name (e.g. `ip-172-31-7-227.ap-southeast-2.compute.internal`) is the VPC-internal name and is **not** reachable from outside the VPC. It can't be used for ACME validation or for clients on the public internet.
|
|
290
|
+
|
|
291
|
+
### Let's Encrypt refuses `*.amazonaws.com` by policy
|
|
292
|
+
|
|
293
|
+
If you try to issue a cert for the EC2 public DNS, LE returns:
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
HTTP 400 urn:ietf:params:acme:error:rejectedIdentifier
|
|
297
|
+
The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Caddy auto-falls back to **ZeroSSL** (also free, also publicly trusted, no policy block on `amazonaws.com`). Use the email-shorthand form `tls <email>` and Caddy handles the fallback transparently. The right end state is a cert with `issuer=ZeroSSL ECC DV SSL CA 2` — verify with:
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
echo | openssl s_client -connect $HOST:8764 -servername $HOST 2>/dev/null \
|
|
304
|
+
| grep -E "^(issuer=|verify return code)"
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
For long-term peace of mind, use a real domain instead (Route 53 A record pointing at the Elastic IP) — both LE and ZeroSSL issue without restriction and the hostname survives instance replacement.
|
|
308
|
+
|
|
309
|
+
### Caddyfile: don't mix `tls` shorthand with `issuer acme` block
|
|
310
|
+
|
|
311
|
+
```caddyfile
|
|
312
|
+
# WRONG — Caddy errors: "cannot mix issuer subdirective with other issuer-specific subdirectives"
|
|
313
|
+
tls prithvi@example.com {
|
|
314
|
+
issuer acme {
|
|
315
|
+
disable_http_challenge
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
The shorthand `tls <email>` implicitly configures an ACME issuer. Combining it with an explicit `issuer acme { ... }` block conflicts. Pick one form:
|
|
321
|
+
|
|
322
|
+
```caddyfile
|
|
323
|
+
# Form 1: shorthand (requires port 80 open for HTTP-01)
|
|
324
|
+
tls prithvi@example.com
|
|
325
|
+
|
|
326
|
+
# Form 2: explicit block (gives you knobs like disable_http_challenge)
|
|
327
|
+
tls {
|
|
328
|
+
issuer acme {
|
|
329
|
+
email prithvi@example.com
|
|
330
|
+
disable_http_challenge
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### `tls internal` produces a Caddy CA cert, not a public one
|
|
336
|
+
|
|
337
|
+
If you see ACME succeed in milliseconds rather than ~10-30 seconds, look at the cert: it was likely issued by Caddy's local CA, not by an external ACME server. The give-aways are an instant log line and `no OCSP server specified in certificate` warnings (public CAs always embed OCSP URLs). `tls internal` is fine for private-network deployments with cert distribution to clients, but doesn't help when you want public trust.
|
|
338
|
+
|
|
339
|
+
### systemd hardening that breaks Node V8 JIT
|
|
340
|
+
|
|
341
|
+
The hardened service file we ship omits two systemd directives that would otherwise be useful:
|
|
342
|
+
|
|
343
|
+
- `MemoryDenyWriteExecute=true`
|
|
344
|
+
- `LockPersonality=true`
|
|
345
|
+
|
|
346
|
+
Both block the W+X memory mappings V8 needs to JIT JavaScript. Adding them causes the service to silently SIGTRAP at startup with `Result: core-dump` and ~5 MB peak memory — no useful log output. If you customize the unit, leave both off.
|
|
347
|
+
|
|
348
|
+
## Troubleshooting
|
|
349
|
+
|
|
350
|
+
| Symptom | Likely cause | Fix |
|
|
351
|
+
|---|---|---|
|
|
352
|
+
| `Connection refused` on `127.0.0.1:8765` | Service not running | `sudo systemctl status sentinelone-mcp`; check `journalctl -u sentinelone-mcp -n 50`. |
|
|
353
|
+
| 401 on every request | No bearer token, or wrong one | Confirm `Authorization: Bearer <token>` is set; confirm the token is in `/etc/sentinelone-mcp/bearer-tokens.json`. |
|
|
354
|
+
| `tools/call` returns `Error: connect ECONNREFUSED` to `*.sentinelone.net` | S1 creds missing or VM has no outbound to console | `curl -v https://$YOUR_CONSOLE_URL`; check `/etc/sentinelone-mcp/credentials.json`. |
|
|
355
|
+
| Service starts but `Tools: 0 registered` | Code/import error | `journalctl -u sentinelone-mcp -n 100` for the import stack trace. |
|
|
356
|
+
| `502 Bad Gateway` from Caddy | Backend died between Caddy reload and proxy attempt | `systemctl status sentinelone-mcp`. |
|
|
357
|
+
|
|
358
|
+
## Alternative deployments
|
|
359
|
+
|
|
360
|
+
These are supported but not first-class:
|
|
361
|
+
|
|
362
|
+
- **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/sentinelone-mcp@1.1.0` + `CMD ["sentinelone-mcp", "--transport", "http", "--host", "0.0.0.0"]`. Mount creds at `/etc/sentinelone-mcp/credentials.json` and tokens at `/etc/sentinelone-mcp/bearer-tokens.json`.
|
|
363
|
+
|
|
364
|
+
- **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.
|
|
365
|
+
|
|
366
|
+
- **No-auth HTTP on a non-loopback bind.** Possible (set `--host 0.0.0.0` and omit `MCP_BEARER_TOKENS*`) but the server logs a loud warning at startup. Only use if the network itself is trusted (e.g. a Tailscale-only LAN where every node is authenticated upstream).
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Claude Desktop bridge for the remote MCP
|
|
2
|
+
|
|
3
|
+
A small stdio↔HTTPS proxy so Claude Desktop can talk to a team-shared `sentinelone-mcp` server running on a VM. Each team member runs the bridge on their own machine; the bridge sends bearer-authed POSTs to the shared MCP endpoint.
|
|
4
|
+
|
|
5
|
+
## Why this exists
|
|
6
|
+
|
|
7
|
+
Claude Desktop's `claude_desktop_config.json` only accepts stdio-based MCP servers in current stable builds. Adding a remote server via `type: "http"` gets rejected with "not valid MCP server configuration". The bridge wraps the remote HTTPS endpoint as a local stdio process, which Claude Desktop accepts.
|
|
8
|
+
|
|
9
|
+
Claude Cowork and Claude Code don't need this — both support `type: "http"` natively.
|
|
10
|
+
|
|
11
|
+
## What's in the box
|
|
12
|
+
|
|
13
|
+
- [`sentinelone-mcp-bridge.mjs`](./sentinelone-mcp-bridge.mjs) — the script. 40 lines, zero external dependencies. Requires Node.js 18+ (uses the built-in `fetch`).
|
|
14
|
+
|
|
15
|
+
## Install (per team member, one-time)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# Download the script
|
|
19
|
+
mkdir -p ~/.local/bin
|
|
20
|
+
curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/sentinelone-mcp/deploy/bridge/sentinelone-mcp-bridge.mjs \
|
|
21
|
+
-o ~/.local/bin/sentinelone-mcp-bridge.mjs
|
|
22
|
+
chmod +x ~/.local/bin/sentinelone-mcp-bridge.mjs
|
|
23
|
+
|
|
24
|
+
# Confirm Node is on PATH
|
|
25
|
+
node --version # must be 18.0.0 or newer
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Configure Claude Desktop
|
|
29
|
+
|
|
30
|
+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, or `%APPDATA%\Claude\claude_desktop_config.json` on Windows. Add the `sentinelone-mcp` block:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"sentinelone-mcp": {
|
|
36
|
+
"command": "node",
|
|
37
|
+
"args": ["/Users/<you>/.local/bin/sentinelone-mcp-bridge.mjs"],
|
|
38
|
+
"env": {
|
|
39
|
+
"MCP_URL": "https://mcp.s1.internal/mcp",
|
|
40
|
+
"MCP_BEARER": "<your personal bearer token>"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
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/sentinelone-mcp/bearer-tokens.json` on the VM.
|
|
48
|
+
|
|
49
|
+
Quit Claude Desktop fully (Cmd+Q on macOS, not just close the window) and reopen. The 26 tools should appear in the tools list.
|
|
50
|
+
|
|
51
|
+
## Smoke test (without Claude Desktop)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
export MCP_URL='https://mcp.s1.internal/mcp'
|
|
55
|
+
export MCP_BEARER='<your token>'
|
|
56
|
+
|
|
57
|
+
# initialize round trip
|
|
58
|
+
echo '{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"cli","version":"1"}}}' \
|
|
59
|
+
| node ~/.local/bin/sentinelone-mcp-bridge.mjs
|
|
60
|
+
|
|
61
|
+
# tools/list (should return 26)
|
|
62
|
+
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
|
|
63
|
+
| node ~/.local/bin/sentinelone-mcp-bridge.mjs \
|
|
64
|
+
| python3 -c 'import sys,json; print(len(json.load(sys.stdin)["result"]["tools"]), "tools")'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Troubleshooting
|
|
68
|
+
|
|
69
|
+
| Symptom | Likely cause | Fix |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Claude Desktop log: `MCP error -32001: Request timed out` | `MCP_URL` unreachable or wrong path | `curl -sS $MCP_URL` from the same machine. Confirm the URL ends with `/mcp`. |
|
|
72
|
+
| `fetch error: ... UNABLE_TO_GET_ISSUER_CERT_LOCALLY` | Server is using a private CA (e.g. `tls internal`); Node doesn't read the system keychain | Use a publicly-trusted cert on the server (Let's Encrypt or ZeroSSL). See [../README.md#aws-specific-gotchas](../README.md#aws-specific-gotchas). |
|
|
73
|
+
| `bridge fetch error: ... ENOTFOUND` | DNS doesn't resolve `MCP_URL` host | Verify with `nslookup` or `dig`. If using an AWS public DNS, it may have changed; re-check the EC2 console. |
|
|
74
|
+
| 401 from upstream in the log | Wrong / revoked bearer token | Ask the admin for a fresh token; replace `MCP_BEARER`. |
|
|
75
|
+
| Bridge starts but Claude Desktop times out | Node version too old | `node --version` — need 18+. Built-in fetch was added in 18. |
|
|
76
|
+
|
|
77
|
+
## How it works
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
+----------------+ stdio JSON-RPC +--------+ HTTPS POST /mcp +------+
|
|
81
|
+
| Claude Desktop | <------------------------------> | bridge | <---------------------------> | VM |
|
|
82
|
+
+----------------+ +--------+ Bearer auth, JSON in/out +------+
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The bridge reads one JSON-RPC message per line from stdin, POSTs it to `MCP_URL` with the `Authorization: Bearer <token>` header, and writes the JSON-RPC reply to stdout. JSON-RPC notifications (messages with no `id`) get no reply, matching the spec. Errors get translated to a JSON-RPC error envelope so Claude Desktop sees something useful instead of a hung process.
|
|
86
|
+
|
|
87
|
+
There is no session state, no buffering, and no SDK dependency — it's just stdin → fetch → stdout.
|
|
88
|
+
|
|
89
|
+
## Security notes
|
|
90
|
+
|
|
91
|
+
- The bearer token is stored in plaintext in `claude_desktop_config.json`. Treat that file as a secret on each laptop.
|
|
92
|
+
- Rotate bearer tokens by editing `/etc/sentinelone-mcp/bearer-tokens.json` on the VM (see [../README.md#day-2-operations](../README.md#day-2-operations)) and reissuing the new value to each team member.
|
|
93
|
+
- TLS verification uses Node's bundled CA store. For privately-issued certs, set `NODE_EXTRA_CA_CERTS=/path/to/root.pem` in the `env` block. Better: use a publicly-trusted cert on the server so no client-side trust is needed.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Tiny stdio -> HTTPS bridge for the SentinelOne MCP server running on a VM.
|
|
4
|
+
*
|
|
5
|
+
* Why this exists:
|
|
6
|
+
* Claude Desktop's stable claude_desktop_config.json only accepts stdio-based
|
|
7
|
+
* MCP servers; the type:"http" form is rejected. This bridge translates
|
|
8
|
+
* Claude Desktop's stdio JSON-RPC into POSTs against the MCP HTTP endpoint,
|
|
9
|
+
* so a single shared HTTPS MCP can serve a whole team's Claude Desktops.
|
|
10
|
+
*
|
|
11
|
+
* Required environment:
|
|
12
|
+
* MCP_URL full HTTPS endpoint, e.g. https://mcp.s1.internal/mcp
|
|
13
|
+
* MCP_BEARER bearer token (no "Bearer " prefix; the script adds it)
|
|
14
|
+
*
|
|
15
|
+
* Optional:
|
|
16
|
+
* none. Zero external dependencies. Requires Node.js 18+ for built-in fetch.
|
|
17
|
+
*
|
|
18
|
+
* Configure Claude Desktop:
|
|
19
|
+
* {
|
|
20
|
+
* "mcpServers": {
|
|
21
|
+
* "sentinelone-mcp": {
|
|
22
|
+
* "command": "node",
|
|
23
|
+
* "args": ["/Users/<you>/.local/bin/sentinelone-mcp-bridge.mjs"],
|
|
24
|
+
* "env": {
|
|
25
|
+
* "MCP_URL": "https://mcp.s1.internal/mcp",
|
|
26
|
+
* "MCP_BEARER": "<your bearer token>"
|
|
27
|
+
* }
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
*
|
|
32
|
+
* Smoke test:
|
|
33
|
+
* MCP_URL=... MCP_BEARER=... bash -c '
|
|
34
|
+
* echo "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}" \
|
|
35
|
+
* | node sentinelone-mcp-bridge.mjs'
|
|
36
|
+
* # -> JSON-RPC response with 26 tools in result.tools[]
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { createInterface } from 'node:readline';
|
|
40
|
+
|
|
41
|
+
const URL = process.env.MCP_URL || (() => { throw new Error('MCP_URL not set'); })();
|
|
42
|
+
const BEARER = process.env.MCP_BEARER || (() => { throw new Error('MCP_BEARER not set'); })();
|
|
43
|
+
|
|
44
|
+
const log = (...a) => process.stderr.write('[bridge] ' + a.join(' ') + '\n');
|
|
45
|
+
|
|
46
|
+
log('starting; target', URL);
|
|
47
|
+
|
|
48
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
49
|
+
|
|
50
|
+
let inFlight = 0;
|
|
51
|
+
let stdinClosed = false;
|
|
52
|
+
function maybeExit() {
|
|
53
|
+
if (stdinClosed && inFlight === 0) {
|
|
54
|
+
log('all requests complete, exiting');
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
rl.on('line', async (line) => {
|
|
60
|
+
const raw = line.trim();
|
|
61
|
+
if (!raw) return;
|
|
62
|
+
|
|
63
|
+
let msg;
|
|
64
|
+
try { msg = JSON.parse(raw); }
|
|
65
|
+
catch (e) { log('bad json from stdin:', e.message); return; }
|
|
66
|
+
|
|
67
|
+
const isNotification = msg.id === undefined;
|
|
68
|
+
|
|
69
|
+
inFlight++;
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(URL, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Content-Type': 'application/json',
|
|
75
|
+
'Authorization': `Bearer ${BEARER}`,
|
|
76
|
+
'Accept': 'application/json',
|
|
77
|
+
},
|
|
78
|
+
body: raw,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (isNotification) return;
|
|
82
|
+
|
|
83
|
+
const text = await res.text();
|
|
84
|
+
if (!res.ok) log(`HTTP ${res.status} from upstream: ${text.slice(0, 200)}`);
|
|
85
|
+
process.stdout.write(text.trimEnd() + '\n');
|
|
86
|
+
} catch (e) {
|
|
87
|
+
const cause = e.cause ? ` cause=${e.cause.code || e.cause.message || JSON.stringify(e.cause)}` : '';
|
|
88
|
+
log('fetch error:', e.message, cause);
|
|
89
|
+
if (!isNotification) {
|
|
90
|
+
process.stdout.write(JSON.stringify({
|
|
91
|
+
jsonrpc: '2.0',
|
|
92
|
+
id: msg.id ?? null,
|
|
93
|
+
error: { code: -32603, message: `bridge fetch error: ${e.message}${cause}` },
|
|
94
|
+
}) + '\n');
|
|
95
|
+
}
|
|
96
|
+
} finally {
|
|
97
|
+
inFlight--;
|
|
98
|
+
maybeExit();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
rl.on('close', () => { log('stdin closed, draining...'); stdinClosed = true; maybeExit(); });
|
|
103
|
+
process.on('SIGINT', () => process.exit(0));
|
|
104
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Caddyfile for sentinelone-mcp behind TLS on a private network.
|
|
2
|
+
#
|
|
3
|
+
# Two adjustments before using:
|
|
4
|
+
# 1. Replace mcp.s1.internal with your DNS name (or Tailscale hostname).
|
|
5
|
+
# 2. Decide on a TLS strategy:
|
|
6
|
+
# tls internal Caddy's built-in CA (good for private networks;
|
|
7
|
+
# distribute the root cert to clients).
|
|
8
|
+
# tls <email> Let's Encrypt over HTTP-01 (needs public DNS + port 80).
|
|
9
|
+
# tls /path/to/cert.pem /path/to/key.pem
|
|
10
|
+
# Bring your own cert from an internal PKI.
|
|
11
|
+
#
|
|
12
|
+
# AWS users: Let's Encrypt refuses to issue for *.amazonaws.com hostnames by
|
|
13
|
+
# policy. When you use the `tls <email>` shorthand, Caddy automatically falls
|
|
14
|
+
# back to ZeroSSL (also free, also publicly trusted, no policy block). The
|
|
15
|
+
# end-state cert should have `issuer=ZeroSSL ECC DV SSL CA 2`. Port 80 must
|
|
16
|
+
# be open in the SG for HTTP-01 to complete.
|
|
17
|
+
#
|
|
18
|
+
# DO NOT mix `tls <email>` with an `issuer acme { ... }` block — Caddy errors
|
|
19
|
+
# with "cannot mix issuer subdirective with other issuer-specific subdirectives".
|
|
20
|
+
# Pick one form: the email shorthand on its own line, OR a full `tls { ... }`
|
|
21
|
+
# block with the email moved inside the `issuer acme` stanza. Not both.
|
|
22
|
+
#
|
|
23
|
+
# Why Caddy and not nginx? Caddy auto-renews certs, has zero config for SSE
|
|
24
|
+
# streaming (flush_interval handled), and is one binary with no system Python
|
|
25
|
+
# or Lua dependency. Equally valid alternative: nginx with the snippet at the
|
|
26
|
+
# bottom of this file.
|
|
27
|
+
|
|
28
|
+
mcp.s1.internal {
|
|
29
|
+
tls internal
|
|
30
|
+
|
|
31
|
+
# Strict bearer-token enforcement. The MCP server also enforces tokens
|
|
32
|
+
# when MCP_BEARER_TOKENS_FILE is set, but checking here too means we never
|
|
33
|
+
# forward unauthenticated traffic to the backend.
|
|
34
|
+
#
|
|
35
|
+
# To bypass Caddy auth (and rely solely on the MCP server's enforcement),
|
|
36
|
+
# delete the @authorized matcher and the handle blocks below, leaving only
|
|
37
|
+
# the reverse_proxy.
|
|
38
|
+
@anyAuth header_regexp Authorization "^Bearer\s+\S+$"
|
|
39
|
+
|
|
40
|
+
handle @anyAuth {
|
|
41
|
+
reverse_proxy 127.0.0.1:8765 {
|
|
42
|
+
# MCP responses are streamed; flush immediately so partial replies
|
|
43
|
+
# reach the client without sitting in a buffer.
|
|
44
|
+
flush_interval -1
|
|
45
|
+
|
|
46
|
+
# Reasonable timeouts for long-running PowerQueries (LRQ can take
|
|
47
|
+
# ~30s on heavy queries). Increase if you see 504s on big queries.
|
|
48
|
+
transport http {
|
|
49
|
+
read_timeout 90s
|
|
50
|
+
write_timeout 90s
|
|
51
|
+
response_header_timeout 30s
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
handle {
|
|
57
|
+
respond "unauthorized" 401 {
|
|
58
|
+
close
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Standard security headers.
|
|
63
|
+
header {
|
|
64
|
+
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
|
65
|
+
X-Content-Type-Options "nosniff"
|
|
66
|
+
Referrer-Policy "no-referrer"
|
|
67
|
+
-Server
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Healthcheck pass-through (no auth needed on /healthz).
|
|
71
|
+
@health path /healthz /health
|
|
72
|
+
handle @health {
|
|
73
|
+
reverse_proxy 127.0.0.1:8765
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Access log to journald via systemd-journald, structured JSON.
|
|
77
|
+
log {
|
|
78
|
+
output stdout
|
|
79
|
+
format json
|
|
80
|
+
level INFO
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# ─── nginx equivalent (commented; copy if you prefer nginx) ──────────────────
|
|
85
|
+
# upstream mcp_backend {
|
|
86
|
+
# server 127.0.0.1:8765;
|
|
87
|
+
# keepalive 16;
|
|
88
|
+
# }
|
|
89
|
+
# server {
|
|
90
|
+
# listen 443 ssl http2;
|
|
91
|
+
# server_name mcp.s1.internal;
|
|
92
|
+
# ssl_certificate /etc/ssl/certs/mcp.s1.internal.crt;
|
|
93
|
+
# ssl_certificate_key /etc/ssl/private/mcp.s1.internal.key;
|
|
94
|
+
#
|
|
95
|
+
# location = /healthz {
|
|
96
|
+
# proxy_pass http://mcp_backend;
|
|
97
|
+
# }
|
|
98
|
+
#
|
|
99
|
+
# location / {
|
|
100
|
+
# if ($http_authorization !~ "^Bearer\s+\S+$") {
|
|
101
|
+
# return 401;
|
|
102
|
+
# }
|
|
103
|
+
# proxy_pass http://mcp_backend;
|
|
104
|
+
# proxy_http_version 1.1;
|
|
105
|
+
# proxy_set_header Connection "";
|
|
106
|
+
# proxy_buffering off;
|
|
107
|
+
# proxy_read_timeout 90s;
|
|
108
|
+
# proxy_send_timeout 90s;
|
|
109
|
+
# }
|
|
110
|
+
# }
|