@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.
- package/CHANGELOG.md +119 -0
- package/README.md +519 -0
- package/deploy/README.md +370 -0
- package/deploy/bridge/README.md +93 -0
- package/deploy/bridge/sentinelone-mcp-bridge.mjs +122 -0
- package/deploy/caddy/Caddyfile.example +110 -0
- package/deploy/install.sh +280 -0
- package/deploy/systemd/s1-secops-mcp.service +59 -0
- package/index.js +171 -0
- package/lib/auth.js +161 -0
- package/lib/credentials.js +124 -0
- package/lib/hec.js +144 -0
- package/lib/http-transport.js +289 -0
- package/lib/s1.js +610 -0
- package/lib/sdl.js +130 -0
- package/lib/server-core.js +263 -0
- package/lib/stdio-transport.js +77 -0
- package/lib/uam-ingest.js +444 -0
- package/package.json +50 -0
- package/scripts/regen-readme-tools-table.mjs +142 -0
- package/scripts/smoke-test-http.sh +125 -0
- package/scripts/test-mac.sh +187 -0
- package/tools/hyperautomation.js +284 -0
- package/tools/mgmt-console.js +344 -0
- package/tools/powerquery.js +129 -0
- package/tools/sdl-api.js +125 -0
- package/tools/uam-ingest.js +128 -0
package/deploy/README.md
ADDED
|
@@ -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));
|