@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.
@@ -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
+ # }