@pmoses-s1/s1-secops-mcp 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ # Deployment guide
2
+
3
+ The canonical team-VM walkthrough for most users is **[docs/vm-deployment.md](../../docs/vm-deployment.md)** (one-line install, per-user bearer tokens, Caddy TLS, client config, day-2 ops). This file is the full deployment reference behind it: all three topologies below, plus the AWS-specific gotchas and internals the walkthrough links to. Credential keys are in [docs/credentials.md](../../docs/credentials.md).
4
+
5
+ Three supported topologies, in order of complexity.
6
+
7
+ | Topology | Who runs it | Transport | Auth | Use this when |
8
+ |---|---|---|---|---|
9
+ | **A. Single user, local** | One human | stdio | none | You use Claude Desktop / Claude Code / Claude Cowork on your own Mac or Linux laptop. |
10
+ | **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. |
11
+ | **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. |
12
+
13
+ ## A. Single user, local (stdio)
14
+
15
+ Download the installer, review it, then run it (avoid piping a remote script straight into a shell). For production, pin the URL to a tagged release commit instead of `main`:
16
+
17
+ ```bash
18
+ curl -fsSL -o /tmp/s1-mcp-install.sh https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/s1-secops-mcp/deploy/install.sh
19
+ # review /tmp/s1-mcp-install.sh, then:
20
+ bash /tmp/s1-mcp-install.sh --user
21
+ ```
22
+
23
+ That runs `install.sh --user`, which:
24
+ 1. Confirms Node 18+ is present (errors out with install hints if not).
25
+ 2. Sets up a per-user npm prefix at `~/.npm-global` if one isn't configured.
26
+ 3. Installs `@pmoses-s1/s1-secops-mcp` globally for your user.
27
+ 4. Writes a credentials skeleton to `~/.config/sentinelone/credentials.json` (mode 0600).
28
+ 5. Prints the next steps.
29
+
30
+ Then edit `~/.config/sentinelone/credentials.json` with your real values:
31
+
32
+ ```json
33
+ {
34
+ "S1_CONSOLE_URL": "https://usea1-yourorg.sentinelone.net",
35
+ "S1_CONSOLE_API_TOKEN": "eyJ...",
36
+ "S1_HEC_INGEST_URL": "https://ingest.us1.sentinelone.net",
37
+ }
38
+ ```
39
+
40
+ 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):
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "s1-secops-mcp": {
46
+ "command": "s1-secops-mcp"
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ Or, equivalently, by package name without the install:
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "s1-secops-mcp": {
58
+ "command": "npx",
59
+ "args": ["-y", "@pmoses-s1/s1-secops-mcp@1.3.0"]
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ Restart Claude Desktop. The server picks credentials up from `~/.config/sentinelone/credentials.json` automatically.
66
+
67
+ ## B. Single user, HTTP
68
+
69
+ Same `install.sh --user`, then start the server in HTTP mode:
70
+
71
+ ```bash
72
+ s1-secops-mcp --transport http
73
+ ```
74
+
75
+ 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:
76
+
77
+ ```bash
78
+ curl -s http://127.0.0.1:8765/healthz
79
+ # -> ok
80
+
81
+ curl -s -X POST http://127.0.0.1:8765/mcp \
82
+ -H 'Content-Type: application/json' \
83
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools | length'
84
+ # -> 26
85
+ ```
86
+
87
+ In Claude Cowork or any MCP client that supports remote HTTP servers, add it:
88
+
89
+ ```json
90
+ {
91
+ "mcpServers": {
92
+ "s1-secops-mcp": {
93
+ "type": "http",
94
+ "url": "http://127.0.0.1:8765/mcp"
95
+ }
96
+ }
97
+ }
98
+ ```
99
+
100
+ ## C. Team, VM-hosted (recommended for shared deployments)
101
+
102
+ 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.
103
+
104
+ ### What you'll end up with
105
+
106
+ - One Linux VM, reachable on your private network (or via Tailscale, WireGuard, etc.).
107
+ - One `mcp` system user owning `/etc/s1-secops-mcp/`.
108
+ - One `credentials.json` containing the S1 service-user token + SDL keys. Mode 0600, never copied off the box.
109
+ - One `bearer-tokens.json` listing per-user tokens, one per team member: `{"alice": "...", "bob": "...", "claire": "..."}`. Mode 0600. SIGHUP-reloadable.
110
+ - One systemd service running the MCP on `127.0.0.1:8765` with auth enforced.
111
+ - Caddy in front terminating TLS and forwarding to the backend.
112
+
113
+ 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`.
114
+
115
+ ### Step-by-step
116
+
117
+ 1. **Provision the VM.** Anything that runs systemd is fine: Ubuntu 22.04 LTS, Debian 12, Rocky/Alma 9, etc.
118
+
119
+ 2. **Install Node 18+.** Pick one:
120
+ ```bash
121
+ # Ubuntu / Debian
122
+ curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
123
+ sudo apt install -y nodejs
124
+ ```
125
+ ```bash
126
+ # Rocky / Alma
127
+ curl -fsSL https://rpm.nodesource.com/setup_20.x | sudo bash -
128
+ sudo dnf install -y nodejs
129
+ ```
130
+
131
+ 3. **Run the installer in server mode:**
132
+ ```bash
133
+ curl -fsSL https://raw.githubusercontent.com/pmoses-s1/claude-skills/main/s1-secops-mcp/deploy/install.sh | sudo bash -s -- --server
134
+ ```
135
+ It creates the `mcp` user, drops `/etc/s1-secops-mcp/credentials.json` (placeholder) and `/etc/s1-secops-mcp/bearer-tokens.json` (one freshly-generated admin token, printed once to stdout), installs the systemd unit, and starts the service.
136
+
137
+ 4. **Fill in real SentinelOne credentials:**
138
+ ```bash
139
+ sudo vim /etc/s1-secops-mcp/credentials.json
140
+ sudo systemctl reload s1-secops-mcp
141
+ curl -s http://127.0.0.1:8765/healthz # -> ok
142
+ ```
143
+
144
+ 5. **Put TLS in front with Caddy** (the recommended option):
145
+ ```bash
146
+ sudo apt install -y caddy
147
+ sudo cp /usr/lib/node_modules/@pmoses-s1/s1-secops-mcp/deploy/caddy/Caddyfile.example /etc/caddy/Caddyfile
148
+ sudo vim /etc/caddy/Caddyfile # change mcp.s1.internal to your DNS name
149
+ sudo systemctl reload caddy
150
+ ```
151
+ 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.
152
+
153
+ 6. **Add team members.** Generate a token per person and append to the file:
154
+ ```bash
155
+ sudo bash -c 'cat > /etc/s1-secops-mcp/bearer-tokens.json' <<EOF
156
+ {
157
+ "admin": "$(openssl rand -hex 32)",
158
+ "alice": "$(openssl rand -hex 32)",
159
+ "bob": "$(openssl rand -hex 32)",
160
+ "claire":"$(openssl rand -hex 32)"
161
+ }
162
+ EOF
163
+ sudo chmod 600 /etc/s1-secops-mcp/bearer-tokens.json
164
+ sudo chown mcp:mcp /etc/s1-secops-mcp/bearer-tokens.json
165
+ sudo systemctl reload s1-secops-mcp # SIGHUP, no downtime
166
+ ```
167
+ Hand each person their token over a secure channel (1Password, Signal, etc.).
168
+
169
+ 7. **Connect from a Claude client.** Each user adds the server to their config with their personal token:
170
+ ```json
171
+ {
172
+ "mcpServers": {
173
+ "s1-secops-mcp": {
174
+ "type": "http",
175
+ "url": "https://mcp.s1.internal/mcp",
176
+ "headers": {
177
+ "Authorization": "Bearer <THEIR_PERSONAL_TOKEN>"
178
+ }
179
+ }
180
+ }
181
+ }
182
+ ```
183
+
184
+ 8. **Verify end-to-end.** From a team member's machine:
185
+ ```bash
186
+ curl -s -X POST https://mcp.s1.internal/mcp \
187
+ -H "Authorization: Bearer $TOKEN" \
188
+ -H 'Content-Type: application/json' \
189
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq '.result.tools | length'
190
+ # -> 26
191
+ ```
192
+
193
+ 9. **Watch the audit log.** Every authenticated request is logged with the bearer name, method, and param summary:
194
+ ```bash
195
+ sudo journalctl -u s1-secops-mcp -f | grep '\[audit\]'
196
+ # [audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
197
+ # [audit] 2026-05-28T15:01:34.221Z | bob | tools/list | - | 200 ok
198
+ ```
199
+
200
+ ## Day-2 operations
201
+
202
+ ### Adding a team member
203
+
204
+ ```bash
205
+ sudo vim /etc/s1-secops-mcp/bearer-tokens.json # add new {"name": "token"}
206
+ sudo systemctl reload s1-secops-mcp # SIGHUP, no downtime
207
+ ```
208
+
209
+ ### Revoking access
210
+
211
+ ```bash
212
+ sudo vim /etc/s1-secops-mcp/bearer-tokens.json # remove the entry
213
+ sudo systemctl reload s1-secops-mcp
214
+ ```
215
+
216
+ ### Rotating the SentinelOne service-user token
217
+
218
+ ```bash
219
+ sudo vim /etc/s1-secops-mcp/credentials.json # paste new S1_CONSOLE_API_TOKEN
220
+ sudo systemctl restart s1-secops-mcp # full restart needed for creds
221
+ ```
222
+
223
+ ### Upgrading the MCP server
224
+
225
+ ```bash
226
+ sudo npm install -g @pmoses-s1/s1-secops-mcp@<new-version>
227
+ sudo systemctl restart s1-secops-mcp
228
+ ```
229
+
230
+ ### Reading the audit log
231
+
232
+ The structured audit lines look like:
233
+
234
+ ```
235
+ [audit] 2026-05-28T15:01:22.413Z | alice | tools/call | name=powerquery_run | 200 ok
236
+ [audit] 2026-05-28T16:42:55.108Z | bob | tools/list | - | 200 ok
237
+ [audit] 2026-05-28T17:03:11.221Z | - | - | - | 401 unauthorized
238
+ ```
239
+
240
+ Quick filters:
241
+
242
+ ```bash
243
+ # everything alice did in the last hour
244
+ sudo journalctl -u s1-secops-mcp --since="1 hour ago" | grep '\[audit\].*| alice |'
245
+
246
+ # all unauthorized attempts today
247
+ sudo journalctl -u s1-secops-mcp --since=today | grep '\[audit\].*401'
248
+
249
+ # all tool calls (not just listings)
250
+ sudo journalctl -u s1-secops-mcp -f | grep 'tools/call'
251
+ ```
252
+
253
+ ### Health and readiness
254
+
255
+ `GET /healthz` returns `200 ok` whenever the server is accepting connections. Use it for load balancer probes and for `systemctl-aware` orchestrators:
256
+
257
+ ```bash
258
+ curl -s http://127.0.0.1:8765/healthz # behind the proxy
259
+ curl -s https://mcp.s1.internal/healthz # in front of the proxy
260
+ ```
261
+
262
+ ## Connecting Claude Desktop to a remote MCP
263
+
264
+ 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/s1-secops-mcp-bridge.mjs`](./bridge/s1-secops-mcp-bridge.mjs), a 40-line zero-dependency Node script that translates Claude Desktop's stdio into POST requests against the MCP HTTP endpoint.
265
+
266
+ Each team member drops the script anywhere on their machine (typically `~/.local/bin/s1-secops-mcp-bridge.mjs`) and points Claude Desktop at it:
267
+
268
+ ```json
269
+ {
270
+ "mcpServers": {
271
+ "s1-secops-mcp": {
272
+ "command": "node",
273
+ "args": ["/Users/<you>/.local/bin/s1-secops-mcp-bridge.mjs"],
274
+ "env": {
275
+ "MCP_URL": "https://mcp.s1.internal/mcp",
276
+ "MCP_BEARER": "<your personal bearer token>"
277
+ }
278
+ }
279
+ }
280
+ }
281
+ ```
282
+
283
+ 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.
284
+
285
+ ## AWS-specific gotchas
286
+
287
+ Five things that bit during real deployment to an EC2 instance. None are blockers, but knowing them up front saves hours.
288
+
289
+ ### EC2 public DNS is unstable without an Elastic IP
290
+
291
+ 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.
292
+
293
+ 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.
294
+
295
+ ### Let's Encrypt refuses `*.amazonaws.com` by policy
296
+
297
+ If you try to issue a cert for the EC2 public DNS, LE returns:
298
+
299
+ ```
300
+ HTTP 400 urn:ietf:params:acme:error:rejectedIdentifier
301
+ The ACME server refuses to issue a certificate for this domain name, because it is forbidden by policy
302
+ ```
303
+
304
+ 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:
305
+
306
+ ```bash
307
+ echo | openssl s_client -connect $HOST:8764 -servername $HOST 2>/dev/null \
308
+ | grep -E "^(issuer=|verify return code)"
309
+ ```
310
+
311
+ 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.
312
+
313
+ ### Caddyfile: don't mix `tls` shorthand with `issuer acme` block
314
+
315
+ ```caddyfile
316
+ # WRONG: Caddy errors: "cannot mix issuer subdirective with other issuer-specific subdirectives"
317
+ tls prithvi@example.com {
318
+ issuer acme {
319
+ disable_http_challenge
320
+ }
321
+ }
322
+ ```
323
+
324
+ The shorthand `tls <email>` implicitly configures an ACME issuer. Combining it with an explicit `issuer acme { ... }` block conflicts. Pick one form:
325
+
326
+ ```caddyfile
327
+ # Form 1: shorthand (requires port 80 open for HTTP-01)
328
+ tls prithvi@example.com
329
+
330
+ # Form 2: explicit block (gives you knobs like disable_http_challenge)
331
+ tls {
332
+ issuer acme {
333
+ email prithvi@example.com
334
+ disable_http_challenge
335
+ }
336
+ }
337
+ ```
338
+
339
+ ### `tls internal` produces a Caddy CA cert, not a public one
340
+
341
+ 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.
342
+
343
+ ### systemd hardening that breaks Node V8 JIT
344
+
345
+ The hardened service file we ship omits two systemd directives that would otherwise be useful:
346
+
347
+ - `MemoryDenyWriteExecute=true`
348
+ - `LockPersonality=true`
349
+
350
+ 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.
351
+
352
+ ## Troubleshooting
353
+
354
+ | Symptom | Likely cause | Fix |
355
+ |---|---|---|
356
+ | `Connection refused` on `127.0.0.1:8765` | Service not running | `sudo systemctl status s1-secops-mcp`; check `journalctl -u s1-secops-mcp -n 50`. |
357
+ | 401 on every request | No bearer token, or wrong one | Confirm `Authorization: Bearer <token>` is set; confirm the token is in `/etc/s1-secops-mcp/bearer-tokens.json`. |
358
+ | `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/s1-secops-mcp/credentials.json`. |
359
+ | Service starts but `Tools: 0 registered` | Code/import error | `journalctl -u s1-secops-mcp -n 100` for the import stack trace. |
360
+ | `502 Bad Gateway` from Caddy | Backend died between Caddy reload and proxy attempt | `systemctl status s1-secops-mcp`. |
361
+
362
+ ## Alternative deployments
363
+
364
+ These are supported but not first-class:
365
+
366
+ - **Docker / docker-compose.** Not shipped in this version. The single-file Node binary doesn't need it. If you want a container, the install is `FROM node:20-alpine` + `RUN npm install -g @pmoses-s1/s1-secops-mcp@1.3.0` + `CMD ["s1-secops-mcp", "--transport", "http", "--host", "0.0.0.0"]`. Mount creds at `/etc/s1-secops-mcp/credentials.json` and tokens at `/etc/s1-secops-mcp/bearer-tokens.json`.
367
+
368
+ - **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.
369
+
370
+ - **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 `s1-secops-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
+ - [`s1-secops-mcp-bridge.mjs`](./s1-secops-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/s1-secops-mcp/deploy/bridge/s1-secops-mcp-bridge.mjs \
21
+ -o ~/.local/bin/s1-secops-mcp-bridge.mjs
22
+ chmod +x ~/.local/bin/s1-secops-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 `s1-secops-mcp` block:
31
+
32
+ ```json
33
+ {
34
+ "mcpServers": {
35
+ "s1-secops-mcp": {
36
+ "command": "node",
37
+ "args": ["/Users/<you>/.local/bin/s1-secops-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/s1-secops-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/s1-secops-mcp-bridge.mjs
60
+
61
+ # tools/list (should return 26)
62
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' \
63
+ | node ~/.local/bin/s1-secops-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/s1-secops-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,122 @@
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
+ * "s1-secops-mcp": {
22
+ * "command": "node",
23
+ * "args": ["/Users/<you>/.local/bin/s1-secops-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 s1-secops-mcp-bridge.mjs'
36
+ * # -> JSON-RPC response with 26 tools in result.tools[]
37
+ */
38
+
39
+ import { createInterface } from 'node:readline';
40
+
41
+ // Named MCP_URL (not URL) so the global URL constructor is not shadowed.
42
+ const MCP_URL = process.env.MCP_URL || (() => { throw new Error('MCP_URL not set'); })();
43
+ const BEARER = process.env.MCP_BEARER || (() => { throw new Error('MCP_BEARER not set'); })();
44
+
45
+ const log = (...a) => process.stderr.write('[bridge] ' + a.join(' ') + '\n');
46
+
47
+ log('starting; target', MCP_URL);
48
+
49
+ const rl = createInterface({ input: process.stdin, terminal: false });
50
+
51
+ let inFlight = 0;
52
+ let stdinClosed = false;
53
+ function maybeExit() {
54
+ if (stdinClosed && inFlight === 0) {
55
+ log('all requests complete, exiting');
56
+ process.exit(0);
57
+ }
58
+ }
59
+
60
+ rl.on('line', async (line) => {
61
+ const raw = line.trim();
62
+ if (!raw) return;
63
+
64
+ let msg;
65
+ try { msg = JSON.parse(raw); }
66
+ catch (e) { log('bad json from stdin:', e.message); return; }
67
+
68
+ const isNotification = msg.id === undefined;
69
+
70
+ inFlight++;
71
+ try {
72
+ const res = await fetch(MCP_URL, {
73
+ method: 'POST',
74
+ headers: {
75
+ 'Content-Type': 'application/json',
76
+ 'Authorization': `Bearer ${BEARER}`,
77
+ 'Accept': 'application/json',
78
+ },
79
+ body: raw,
80
+ // A hung upstream must not wedge the bridge forever; 120s covers the
81
+ // slowest LRQ tool calls with headroom.
82
+ signal: AbortSignal.timeout(120000),
83
+ });
84
+
85
+ if (isNotification) {
86
+ // Drain the body so the keep-alive socket is released.
87
+ await res.text().catch(() => {});
88
+ return;
89
+ }
90
+
91
+ const text = await res.text();
92
+ if (!res.ok) {
93
+ // Non-OK upstream: emit a JSON-RPC error envelope (not raw text/HTML) so the
94
+ // client always receives valid JSON-RPC and a meaningful diagnostic.
95
+ log(`HTTP ${res.status} from upstream: ${text.slice(0, 200)}`);
96
+ process.stdout.write(JSON.stringify({
97
+ jsonrpc: '2.0',
98
+ id: msg.id ?? null,
99
+ error: { code: -32603, message: `upstream HTTP ${res.status}`, data: text.slice(0, 500) },
100
+ }) + '\n');
101
+ return;
102
+ }
103
+ process.stdout.write(text.trimEnd() + '\n');
104
+ } catch (e) {
105
+ const cause = e.cause ? ` cause=${e.cause.code || e.cause.message || JSON.stringify(e.cause)}` : '';
106
+ log('fetch error:', e.message, cause);
107
+ if (!isNotification) {
108
+ process.stdout.write(JSON.stringify({
109
+ jsonrpc: '2.0',
110
+ id: msg.id ?? null,
111
+ error: { code: -32603, message: `bridge fetch error: ${e.message}${cause}` },
112
+ }) + '\n');
113
+ }
114
+ } finally {
115
+ inFlight--;
116
+ maybeExit();
117
+ }
118
+ });
119
+
120
+ rl.on('close', () => { log('stdin closed, draining...'); stdinClosed = true; maybeExit(); });
121
+ process.on('SIGINT', () => process.exit(0));
122
+ process.on('SIGTERM', () => process.exit(0));