@blacksandscyber/mcp-server-bursar 0.5.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.
Files changed (68) hide show
  1. package/README.md +230 -0
  2. package/build/config.d.ts +45 -0
  3. package/build/config.js +177 -0
  4. package/build/http-transport.d.ts +16 -0
  5. package/build/http-transport.js +191 -0
  6. package/build/index.d.ts +16 -0
  7. package/build/index.js +31 -0
  8. package/build/server.d.ts +41 -0
  9. package/build/server.js +902 -0
  10. package/build/shared/errors.d.ts +50 -0
  11. package/build/shared/errors.js +69 -0
  12. package/build/shared/linkBuilder.d.ts +93 -0
  13. package/build/shared/linkBuilder.js +148 -0
  14. package/build/shared/logger.d.ts +10 -0
  15. package/build/shared/logger.js +28 -0
  16. package/build/shield/bootRole.d.ts +60 -0
  17. package/build/shield/bootRole.js +145 -0
  18. package/build/shield/client.d.ts +265 -0
  19. package/build/shield/client.js +656 -0
  20. package/build/shield/deploy/index.d.ts +69 -0
  21. package/build/shield/deploy/index.js +569 -0
  22. package/build/shield/discovery/dataStoreDetector.d.ts +3 -0
  23. package/build/shield/discovery/dataStoreDetector.js +125 -0
  24. package/build/shield/discovery/dockerScanner.d.ts +34 -0
  25. package/build/shield/discovery/dockerScanner.js +543 -0
  26. package/build/shield/discovery/endpointScanner.d.ts +3 -0
  27. package/build/shield/discovery/endpointScanner.js +306 -0
  28. package/build/shield/discovery/environmentScanner.d.ts +86 -0
  29. package/build/shield/discovery/environmentScanner.js +545 -0
  30. package/build/shield/discovery/externalServiceDetector.d.ts +3 -0
  31. package/build/shield/discovery/externalServiceDetector.js +98 -0
  32. package/build/shield/discovery/frameworkDetector.d.ts +3 -0
  33. package/build/shield/discovery/frameworkDetector.js +114 -0
  34. package/build/shield/discovery/manifestGenerator.d.ts +12 -0
  35. package/build/shield/discovery/manifestGenerator.js +124 -0
  36. package/build/shield/discovery/piiDetector.d.ts +5 -0
  37. package/build/shield/discovery/piiDetector.js +203 -0
  38. package/build/shield/discovery/severity.d.ts +47 -0
  39. package/build/shield/discovery/severity.js +138 -0
  40. package/build/shield/discovery/topologyNormalizer.d.ts +109 -0
  41. package/build/shield/discovery/topologyNormalizer.js +416 -0
  42. package/build/shield/identity.d.ts +53 -0
  43. package/build/shield/identity.js +70 -0
  44. package/build/shield/install/configMerge.d.ts +91 -0
  45. package/build/shield/install/configMerge.js +324 -0
  46. package/build/shield/install/keystore.d.ts +25 -0
  47. package/build/shield/install/keystore.js +156 -0
  48. package/build/shield/install/orchestrator.d.ts +33 -0
  49. package/build/shield/install/orchestrator.js +404 -0
  50. package/build/shield/install/transports/awsSsm.d.ts +43 -0
  51. package/build/shield/install/transports/awsSsm.js +378 -0
  52. package/build/shield/install/transports/bootstrapToken.d.ts +39 -0
  53. package/build/shield/install/transports/bootstrapToken.js +117 -0
  54. package/build/shield/install/transports/ssh.d.ts +50 -0
  55. package/build/shield/install/transports/ssh.js +569 -0
  56. package/build/shield/install/types.d.ts +139 -0
  57. package/build/shield/install/types.js +10 -0
  58. package/build/shield/protocol-walkthrough.d.ts +65 -0
  59. package/build/shield/protocol-walkthrough.js +392 -0
  60. package/build/shield/provision/appProvisioner.d.ts +15 -0
  61. package/build/shield/provision/appProvisioner.js +25 -0
  62. package/build/shield/types.d.ts +261 -0
  63. package/build/shield/types.js +4 -0
  64. package/build/shield/verify/postureReporter.d.ts +4 -0
  65. package/build/shield/verify/postureReporter.js +31 -0
  66. package/dxt/blacksands-ca.crt +67 -0
  67. package/dxt/scripts/setup.js +520 -0
  68. package/package.json +76 -0
@@ -0,0 +1,520 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable no-console */
3
+ /**
4
+ * Blacksands Bursar MCP — DXT bootstrap entry point (Phase 2)
5
+ *
6
+ * This script is the DXT's `server.entry_point`. On each launch it:
7
+ *
8
+ * 1. Checks whether a cert bundle already exists in ~/.blacksands/mcp-certs/.
9
+ * - If yes, skip straight to step 4 (ordinary launch).
10
+ * - If no and a setup_token is configured, redeem it to obtain one.
11
+ *
12
+ * 2. Redeems the token by POSTing to {authorizer_url}/v1/mcp/setup-tokens/redeem
13
+ * (the shield-api endpoint is exposed through the same Authorizer host
14
+ * that the server itself will later talk to, so no extra trust roots).
15
+ *
16
+ * 3. Persists cert + key + ca + auth_password to ~/.blacksands/mcp-certs/
17
+ * with 0600 permissions on secrets. (Future: keychain via keytar; for
18
+ * now, disk-based storage matches the existing admin-issued cert flow.)
19
+ *
20
+ * 4. Spawns the real MCP server (server/build/index.js) with all the env
21
+ * vars it expects (SHIELD_AUTHORIZER_URL, SHIELD_CLIENT_CERT, etc.),
22
+ * forwarding stdio. Exit code is propagated so Claude Desktop sees the
23
+ * same shutdown behavior as before.
24
+ *
25
+ * Environment inputs (injected by Claude Desktop from user_config):
26
+ * SHIELD_AUTHORIZER_URL required — used for redeem AND by the server
27
+ * SHIELD_SETUP_TOKEN optional — triggers redemption on first launch
28
+ * SHIELD_ORG_ID optional — override the org id from the token
29
+ * LOG_LEVEL optional — forwarded to the server
30
+ *
31
+ * The script is written in plain CommonJS with zero external dependencies so
32
+ * it can run inside the bundled DXT without a separate `npm install` step.
33
+ */
34
+
35
+ 'use strict';
36
+
37
+ const fs = require('fs');
38
+ const os = require('os');
39
+ const path = require('path');
40
+ const https = require('https');
41
+ const http = require('http');
42
+ const { URL } = require('url');
43
+ const { spawn } = require('child_process');
44
+
45
+ // ──────────────────────────────────────────────────────────────────────────
46
+ // Paths & constants
47
+ // ──────────────────────────────────────────────────────────────────────────
48
+
49
+ const HOME = os.homedir();
50
+ const CERT_DIR = path.join(HOME, '.blacksands', 'mcp-certs');
51
+ const CLIENT_NAME_FILE = path.join(CERT_DIR, '.client-name');
52
+ const PASSWORD_FILE = path.join(CERT_DIR, '.auth-password');
53
+ const ORG_ID_FILE = path.join(CERT_DIR, '.org-id');
54
+ const ROLE_FILE = path.join(CERT_DIR, '.role'); // 'master' | 'consumer' — read by src/shield/bootRole.ts
55
+ const LAST_TOKEN_FILE = path.join(CERT_DIR, '.last-token-prefix');
56
+ // Where the real MCP server lives. We support two on-disk layouts so this
57
+ // script can be the entry point for both Claude Desktop's DXT bundle AND
58
+ // `npx @blacksandscyber/mcp-server-bursar` (or `claude mcp add shield -- shield-mcp`):
59
+ //
60
+ // DXT bundle: <ext>/server/scripts/setup.js
61
+ // <ext>/server/build/index.js
62
+ // <ext>/blacksands-ca.crt ← copied to .dxt root
63
+ //
64
+ // npm install: <pkg>/dxt/scripts/setup.js
65
+ // <pkg>/build/index.js ← tsc outDir
66
+ // <pkg>/dxt/blacksands-ca.crt ← shipped via "files"
67
+ //
68
+ // Source repo (`node dxt/scripts/setup.js` directly) matches the npm layout.
69
+ function resolveByExisting(/* ...candidates */) {
70
+ for (let i = 0; i < arguments.length; i++) {
71
+ if (fs.existsSync(arguments[i])) return arguments[i];
72
+ }
73
+ // None found — return the first candidate so diagnostics can show the
74
+ // expected DXT-layout path. die() will report the file as missing later.
75
+ return arguments[0];
76
+ }
77
+ const SERVER_ENTRY = resolveByExisting(
78
+ path.resolve(__dirname, '..', 'build', 'index.js'), // DXT bundle
79
+ path.resolve(__dirname, '..', '..', 'build', 'index.js'), // npm install / source repo
80
+ );
81
+ // Bundled Blacksands CA chain (Intermediate + Root). Shipped so the MCP
82
+ // server can verify the Authorizer's private-CA TLS cert on every install —
83
+ // even if the redeem response omitted ca_pem.
84
+ const BUNDLED_CA = resolveByExisting(
85
+ path.resolve(__dirname, '..', '..', 'blacksands-ca.crt'), // DXT bundle (copied to root)
86
+ path.resolve(__dirname, '..', 'blacksands-ca.crt'), // npm install / source repo
87
+ );
88
+
89
+ // OS keystore module (compiled from src/shield/install/keystore.ts). Optional:
90
+ // older builds may not have it, and it's gated behind BS_MCP_KEYSTORE anyway —
91
+ // so a load failure is non-fatal and we silently keep the disk-only path.
92
+ let keystore = null;
93
+ try {
94
+ const ksPath = resolveByExisting(
95
+ path.resolve(__dirname, '..', 'build', 'shield', 'install', 'keystore.js'), // DXT bundle
96
+ path.resolve(__dirname, '..', '..', 'build', 'shield', 'install', 'keystore.js'), // npm / source
97
+ );
98
+ if (fs.existsSync(ksPath)) keystore = require(ksPath);
99
+ } catch { keystore = null; }
100
+
101
+ const TOKEN_PREFIX = 'bss_';
102
+ const CN_CLIENT_NAME_RE = /^([^.]+)\.mcp\./;
103
+
104
+ // ──────────────────────────────────────────────────────────────────────────
105
+ // Logging — everything goes to stderr so we never corrupt the MCP stdio
106
+ // stream once the child server takes over.
107
+ // ──────────────────────────────────────────────────────────────────────────
108
+
109
+ function log(msg, extra) {
110
+ const prefix = '[shield-mcp-setup]';
111
+ if (extra !== undefined) {
112
+ process.stderr.write(`${prefix} ${msg} ${JSON.stringify(extra)}\n`);
113
+ } else {
114
+ process.stderr.write(`${prefix} ${msg}\n`);
115
+ }
116
+ }
117
+
118
+ function die(msg, extra) {
119
+ log(`FATAL: ${msg}`, extra);
120
+ process.exit(1);
121
+ }
122
+
123
+ // ──────────────────────────────────────────────────────────────────────────
124
+ // HTTP helper (native https/http only; no dependencies)
125
+ // ──────────────────────────────────────────────────────────────────────────
126
+
127
+ function postJson(urlString, body, timeoutMs = 30_000) {
128
+ return new Promise((resolve, reject) => {
129
+ let url;
130
+ try {
131
+ url = new URL(urlString);
132
+ } catch (e) {
133
+ return reject(new Error(`Invalid URL: ${urlString}`));
134
+ }
135
+ const lib = url.protocol === 'http:' ? http : https;
136
+ const payload = Buffer.from(JSON.stringify(body), 'utf8');
137
+
138
+ const req = lib.request(
139
+ {
140
+ method: 'POST',
141
+ hostname: url.hostname,
142
+ port: url.port || (url.protocol === 'http:' ? 80 : 443),
143
+ path: url.pathname + (url.search || ''),
144
+ headers: {
145
+ 'Content-Type': 'application/json',
146
+ 'Content-Length': String(payload.length),
147
+ 'User-Agent': 'blacksands-shield-dxt-setup/0.2',
148
+ Accept: 'application/json',
149
+ },
150
+ timeout: timeoutMs,
151
+ },
152
+ (res) => {
153
+ const chunks = [];
154
+ res.on('data', (c) => chunks.push(c));
155
+ res.on('end', () => {
156
+ const raw = Buffer.concat(chunks).toString('utf8');
157
+ let json = null;
158
+ try { json = raw ? JSON.parse(raw) : null; } catch { /* non-JSON */ }
159
+ resolve({ status: res.statusCode, body: json, raw });
160
+ });
161
+ }
162
+ );
163
+ req.on('timeout', () => req.destroy(new Error(`Request timed out after ${timeoutMs}ms`)));
164
+ req.on('error', reject);
165
+ req.write(payload);
166
+ req.end();
167
+ });
168
+ }
169
+
170
+ // ──────────────────────────────────────────────────────────────────────────
171
+ // Filesystem helpers
172
+ // ──────────────────────────────────────────────────────────────────────────
173
+
174
+ function ensureCertDir() {
175
+ if (!fs.existsSync(CERT_DIR)) {
176
+ fs.mkdirSync(CERT_DIR, { recursive: true, mode: 0o700 });
177
+ log(`Created cert directory: ${CERT_DIR}`);
178
+ } else {
179
+ try { fs.chmodSync(CERT_DIR, 0o700); } catch { /* non-fatal */ }
180
+ }
181
+
182
+ // Seed the Blacksands CA chain if not already present. The Authorizer's TLS
183
+ // certificate is signed by a private Blacksands CA (not a public CA), so
184
+ // Node.js needs this chain to verify the TLS handshake. We bundle the chain
185
+ // in the DXT so it's always available — even when the redeem response omits
186
+ // ca_pem (e.g. older Shield API versions).
187
+ const destCa = path.join(CERT_DIR, 'blacksands-ca.crt');
188
+ if (!fs.existsSync(destCa) && fs.existsSync(BUNDLED_CA)) {
189
+ try {
190
+ fs.copyFileSync(BUNDLED_CA, destCa);
191
+ fs.chmodSync(destCa, 0o644);
192
+ log(`Seeded CA chain from bundle: ${destCa}`);
193
+ } catch (e) {
194
+ log(`Warning: could not seed bundled CA cert: ${e.message}`);
195
+ }
196
+ }
197
+ }
198
+
199
+ function writeSecret(filePath, contents) {
200
+ fs.writeFileSync(filePath, contents, { mode: 0o600 });
201
+ try { fs.chmodSync(filePath, 0o600); } catch { /* non-fatal */ }
202
+ }
203
+
204
+ function writePublic(filePath, contents) {
205
+ fs.writeFileSync(filePath, contents, { mode: 0o644 });
206
+ }
207
+
208
+ function readIfExists(filePath) {
209
+ try { return fs.readFileSync(filePath, 'utf8'); } catch { return null; }
210
+ }
211
+
212
+ // ──────────────────────────────────────────────────────────────────────────
213
+ // Redeem flow
214
+ // ──────────────────────────────────────────────────────────────────────────
215
+
216
+ async function redeemToken(setupUrl, token) {
217
+ // Bootstrap is an onboarding concern — POST to the onboarding-backend's
218
+ // public edge, not the Authorizer. Authorizer does auth; Receiver does
219
+ // proxy; onboarding-backend handles bootstrap. See
220
+ // overview/onboarding-api/routes/mcpInstallRoutes.js.
221
+ const url = `${setupUrl.replace(/\/$/, '')}/api/onboarding/mcp-install/redeem`;
222
+ log(`Redeeming setup token at ${url}`);
223
+ const { status, body, raw } = await postJson(url, { token });
224
+ if (status === 200 || status === 201) {
225
+ if (!body || body.success !== true) {
226
+ throw new Error(`Redeem response not recognized (status ${status}): ${raw.slice(0, 200)}`);
227
+ }
228
+ return body;
229
+ }
230
+ const reason = body?.reason ? ` (${body.reason})` : '';
231
+ const msg = body?.message || raw || `HTTP ${status}`;
232
+ throw new Error(`Token redemption failed${reason}: ${msg}`);
233
+ }
234
+
235
+ function clientNameFromCn(cn) {
236
+ // CN format: "{clientName}.mcp.{domain}"
237
+ if (!cn) return 'mcp-client';
238
+ const m = cn.match(CN_CLIENT_NAME_RE);
239
+ return m ? m[1] : 'mcp-client';
240
+ }
241
+
242
+ async function bootstrap({ setupUrl, token, orgIdOverride }) {
243
+ ensureCertDir();
244
+
245
+ const result = await redeemToken(setupUrl, token);
246
+ const clientName = clientNameFromCn(result.cn);
247
+ const orgId = orgIdOverride || result.org_id || result.orgId;
248
+ if (!result.cert_pem || !result.key_pem) {
249
+ throw new Error('Redeem response missing cert_pem or key_pem');
250
+ }
251
+ if (!result.auth_password) {
252
+ throw new Error('Redeem response missing auth_password');
253
+ }
254
+
255
+ const certPath = path.join(CERT_DIR, `${clientName}.crt`);
256
+ const keyPath = path.join(CERT_DIR, `${clientName}.key`);
257
+ const caPath = path.join(CERT_DIR, 'blacksands-ca.crt');
258
+
259
+ writePublic(certPath, result.cert_pem);
260
+ writeSecret(keyPath, result.key_pem);
261
+ if (result.ca_pem) writePublic(caPath, result.ca_pem);
262
+
263
+ writeSecret(PASSWORD_FILE, result.auth_password);
264
+ writeSecret(CLIENT_NAME_FILE, clientName);
265
+ if (orgId) writeSecret(ORG_ID_FILE, orgId);
266
+ // Persist the role returned by the redeem response so the MCP server's
267
+ // src/shield/bootRole.ts can filter tool registration on next launch
268
+ // without an awaited /v1/mcp/identity call. Tolerate missing role
269
+ // (legacy server response) — the bootRole resolver falls through to
270
+ // the mode-default.
271
+ if (result.role === 'master' || result.role === 'consumer') {
272
+ writeSecret(ROLE_FILE, result.role);
273
+ }
274
+
275
+ // F4.11: when opted in (BS_MCP_KEYSTORE=auto|on) and a backend is available,
276
+ // also store the private key + auth password in the OS keystore. Disk copies
277
+ // are retained as a fallback (no lockout); launchServer prefers the keystore
278
+ // copies when present. Best-effort — never blocks the install.
279
+ if (keystore && keystore.isKeystoreAvailable()) {
280
+ try {
281
+ const okKey = keystore.storeSecret(keystore.secretAccount(clientName, 'key'), result.key_pem);
282
+ const okPw = keystore.storeSecret(keystore.secretAccount(clientName, 'password'), result.auth_password);
283
+ log('Stored MCP secrets in OS keystore', { backend: keystore.detectBackend(), key: okKey, password: okPw });
284
+ } catch (e) {
285
+ log(`Warning: OS keystore store failed (keeping disk copies): ${e.message}`);
286
+ }
287
+ }
288
+
289
+ log('Bootstrap complete', {
290
+ clientName,
291
+ orgId,
292
+ role: result.role || '(not provided by server; will default to master)',
293
+ cn: result.cn,
294
+ certPath,
295
+ keyPath,
296
+ caPath: result.ca_pem ? caPath : null,
297
+ expires: result.expires || null,
298
+ });
299
+
300
+ return {
301
+ clientName,
302
+ orgId,
303
+ certPath,
304
+ keyPath,
305
+ caPath: result.ca_pem ? caPath : null,
306
+ authPassword: result.auth_password,
307
+ };
308
+ }
309
+
310
+ // ──────────────────────────────────────────────────────────────────────────
311
+ // Resolve existing bundle on disk (for subsequent launches)
312
+ // ──────────────────────────────────────────────────────────────────────────
313
+
314
+ function findExistingBundle() {
315
+ if (!fs.existsSync(CERT_DIR)) return null;
316
+
317
+ const clientName = readIfExists(CLIENT_NAME_FILE);
318
+ if (!clientName) return null;
319
+
320
+ const certPath = path.join(CERT_DIR, `${clientName.trim()}.crt`);
321
+ const keyPath = path.join(CERT_DIR, `${clientName.trim()}.key`);
322
+ const caPath = path.join(CERT_DIR, 'blacksands-ca.crt');
323
+
324
+ if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) return null;
325
+
326
+ const authPassword = readIfExists(PASSWORD_FILE);
327
+ if (!authPassword) return null;
328
+
329
+ const orgId = readIfExists(ORG_ID_FILE);
330
+
331
+ return {
332
+ clientName: clientName.trim(),
333
+ orgId: orgId ? orgId.trim() : null,
334
+ certPath,
335
+ keyPath,
336
+ caPath: fs.existsSync(caPath) ? caPath : null,
337
+ authPassword: authPassword.trim(),
338
+ };
339
+ }
340
+
341
+ // ──────────────────────────────────────────────────────────────────────────
342
+ // Launch the real MCP server
343
+ // ──────────────────────────────────────────────────────────────────────────
344
+
345
+ function launchServer(bundle, authorizerUrl) {
346
+ // Set env vars before requiring the server module — the server reads these
347
+ // at load time via config.ts / loadConfig().
348
+ process.env.SHIELD_AUTHORIZER_URL = authorizerUrl;
349
+ process.env.SHIELD_SERVICE_ID = process.env.SHIELD_SERVICE_ID || 'shield-api';
350
+ // Only set credential vars when we have a real bundle (not in local-only mode)
351
+ if (bundle.certPath) process.env.SHIELD_CLIENT_CERT = bundle.certPath;
352
+ if (bundle.keyPath) process.env.SHIELD_CLIENT_KEY = bundle.keyPath;
353
+ if (bundle.authPassword) process.env.SHIELD_AUTH_PASSWORD = bundle.authPassword;
354
+
355
+ // F4.11: prefer keystore-held secrets over the disk copies when available.
356
+ // The private key is passed inline (SHIELD_CLIENT_KEY_PEM) so config.ts uses
357
+ // it directly without a disk read; the password replaces the disk value.
358
+ // Best-effort: on any miss we silently keep the disk-sourced values above.
359
+ if (keystore && bundle.clientName && keystore.isKeystoreAvailable()) {
360
+ try {
361
+ const keyPem = keystore.retrieveSecret(keystore.secretAccount(bundle.clientName, 'key'));
362
+ if (keyPem) process.env.SHIELD_CLIENT_KEY_PEM = keyPem;
363
+ const pw = keystore.retrieveSecret(keystore.secretAccount(bundle.clientName, 'password'));
364
+ if (pw) process.env.SHIELD_AUTH_PASSWORD = pw;
365
+ if (keyPem || pw) log('Loaded MCP secrets from OS keystore', { key: !!keyPem, password: !!pw });
366
+ } catch (e) {
367
+ log(`Warning: OS keystore read failed (using disk copies): ${e.message}`);
368
+ }
369
+ }
370
+ process.env.SHIELD_ORG_ID = process.env.SHIELD_ORG_ID || bundle.orgId || '';
371
+ if (bundle.caPath) process.env.SHIELD_CA_CERT = bundle.caPath;
372
+ if (!process.env.LOG_LEVEL) process.env.LOG_LEVEL = 'info';
373
+
374
+ // Never expose the setup token to the MCP server
375
+ delete process.env.SHIELD_SETUP_TOKEN;
376
+
377
+ if (!fs.existsSync(SERVER_ENTRY)) {
378
+ die(
379
+ 'Blacksands Bursar appears to be incomplete — some files are missing from the extension. ' +
380
+ 'Try uninstalling and reinstalling the extension from https://shield.blacksandscyber.online/download.'
381
+ );
382
+ }
383
+
384
+ log(`Starting MCP server in-process: ${SERVER_ENTRY}`, {
385
+ orgId: process.env.SHIELD_ORG_ID,
386
+ authorizer: process.env.SHIELD_AUTHORIZER_URL,
387
+ });
388
+
389
+ // Run the MCP server in the SAME process rather than spawning a child.
390
+ //
391
+ // Why: Claude Desktop's built-in Node.js runner manages a single process
392
+ // via its stdio pipe. When setup.js calls spawn() with stdio:'inherit', the
393
+ // child correctly inherits the stdio fds, but Claude Desktop appears to
394
+ // monitor the PARENT process specifically. When the parent (setup.js) stays
395
+ // alive but silent while the child handles the MCP protocol, Claude Desktop
396
+ // either kills the parent or sees an idle process and closes the connection.
397
+ //
398
+ // The in-process approach is simpler: we set the env vars that the server
399
+ // reads at startup, then require() the compiled entry point directly. The
400
+ // server's stdio transport binds to process.stdin/stdout which ARE the pipe
401
+ // Claude Desktop is monitoring. No child process, no relay, no race.
402
+ require(SERVER_ENTRY);
403
+ }
404
+
405
+ // ──────────────────────────────────────────────────────────────────────────
406
+ // Main
407
+ // ──────────────────────────────────────────────────────────────────────────
408
+
409
+ function bundleFromEnv() {
410
+ // Phase 1 mode: all three of cert path, key path, and auth password are
411
+ // supplied directly via user_config. No bootstrap needed — just forward.
412
+ const certPath = process.env.SHIELD_CLIENT_CERT;
413
+ const keyPath = process.env.SHIELD_CLIENT_KEY;
414
+ const authPassword = process.env.SHIELD_AUTH_PASSWORD;
415
+ if (!certPath || !keyPath || !authPassword) return null;
416
+ if (!fs.existsSync(certPath) || !fs.existsSync(keyPath)) return null;
417
+ return {
418
+ clientName: 'env-provided',
419
+ orgId: process.env.SHIELD_ORG_ID || null,
420
+ certPath,
421
+ keyPath,
422
+ caPath: process.env.SHIELD_CA_CERT && fs.existsSync(process.env.SHIELD_CA_CERT)
423
+ ? process.env.SHIELD_CA_CERT
424
+ : null,
425
+ authPassword,
426
+ };
427
+ }
428
+
429
+ // Default URLs baked in so the npm/`shield-mcp` CLI works with zero config —
430
+ // `claude mcp add shield -- shield-mcp` just works in local-only mode without
431
+ // the user having to set SHIELD_AUTHORIZER_URL. Claude Desktop's user_config
432
+ // always overrides these via env.
433
+ const DEFAULT_AUTHORIZER_URL = 'https://mauth-beta.blacksandscyber.online';
434
+ const DEFAULT_SETUP_URL = 'https://onboard.beta.blacksandscyber.online';
435
+
436
+ async function main() {
437
+ // Authorizer URL: only required for token redemption + broker handshake.
438
+ // For local-only mode (no token, no cert bundle) the URL is unused, but
439
+ // we still need a sensible default so downstream code can read it without
440
+ // a null check. Fall back to the bundled default rather than failing hard.
441
+ const authorizerUrl = process.env.SHIELD_AUTHORIZER_URL || DEFAULT_AUTHORIZER_URL;
442
+
443
+ // Bootstrap hits the onboarding edge, NOT the Authorizer (separation of
444
+ // powers: Authorizer auths, Receiver proxies, onboarding bootstraps).
445
+ // Fall back to SHIELD_AUTHORIZER_URL if SHIELD_SETUP_URL isn't set so
446
+ // pre-v0.3.2 manifests that conflated the two URLs still attempt redeem —
447
+ // it just won't succeed against a pure Authorizer. New installs get the
448
+ // correct default from manifest-v2.json.
449
+ const setupUrl = (process.env.SHIELD_SETUP_URL || DEFAULT_SETUP_URL).replace(/\/$/, '');
450
+
451
+ const setupToken = (process.env.SHIELD_SETUP_TOKEN || '').trim();
452
+
453
+ // Phase 1: direct cert paths from user_config take the fast path.
454
+ const envBundle = bundleFromEnv();
455
+ if (envBundle && !setupToken) {
456
+ log('Using directly-provided cert bundle from user_config (Phase 1 mode)');
457
+ return launchServer(envBundle, authorizerUrl);
458
+ }
459
+
460
+ // Phase 2: token-based bootstrap
461
+ let bundle = findExistingBundle();
462
+
463
+ // IMPORTANT: we only bootstrap (network call to redeem the token) when there
464
+ // is NO existing cert bundle on disk. If a bundle already exists we launch
465
+ // immediately — no network call, no race with Claude Desktop's stdio timeout.
466
+ //
467
+ // Why: Claude Desktop has a ~4-8 second window for the MCP server to respond
468
+ // to the initialize message. A token-redemption round-trip can easily exceed
469
+ // that window (cert issuance takes 2-6+ s), causing Claude Desktop to close
470
+ // the stdio pipe before the child server ever starts. The child then reads EOF
471
+ // and exits silently — showing "Server disconnected" with no stderr output.
472
+ //
473
+ // Cert rotation workflow: if you need to rotate credentials, delete
474
+ // ~/.blacksands/mcp-certs/ (or run preflight.sh --clean) and restart Claude
475
+ // Desktop. On the next launch the bundle will be absent and bootstrap will run
476
+ // as a one-time first-install operation.
477
+ const shouldBootstrap = !bundle && setupToken && setupToken.startsWith(TOKEN_PREFIX);
478
+
479
+ if (!bundle && !setupToken) {
480
+ // No credentials — start in local-only mode so the 9 [FREE] tools work
481
+ // immediately (codebase scans + the deployment guide). Shield API tools
482
+ // will return a friendly message directing the user to create an account.
483
+ log('No Blacksands Bursar credentials found — starting in local-only mode.');
484
+ log('11 [FREE] tools work without an account: shield_scan_codebase, shield_flag_pii_candidates,');
485
+ log('shield_get_protection_requirements, shield_guide_deployment, etc.');
486
+ log('To unlock full Shield protection, visit https://shield.blacksandscyber.online');
487
+ process.env.MCP_TRANSPORT = 'local-only';
488
+ launchServer({ certPath: null, keyPath: null, caPath: null, authPassword: '', orgId: '' }, authorizerUrl);
489
+ return;
490
+ }
491
+
492
+ if (shouldBootstrap) {
493
+ try {
494
+ bundle = await bootstrap({
495
+ setupUrl,
496
+ token: setupToken,
497
+ orgIdOverride: process.env.SHIELD_ORG_ID || null,
498
+ });
499
+ writeSecret(LAST_TOKEN_FILE, setupToken.slice(0, 12));
500
+ } catch (err) {
501
+ die(
502
+ `Your setup link didn't work — it may have expired (links are valid for a limited time) ` +
503
+ `or already been used (each link is single-use). ` +
504
+ `Ask your administrator for a fresh one, or sign up at https://shield.blacksandscyber.online. ` +
505
+ `(Technical detail: ${err.message})`
506
+ );
507
+ }
508
+ }
509
+
510
+ if (!bundle) {
511
+ die(
512
+ 'Blacksands Bursar could not set up your credentials. ' +
513
+ 'Please try again, or contact support at https://shield.blacksandscyber.online/support.'
514
+ );
515
+ }
516
+
517
+ launchServer(bundle, authorizerUrl);
518
+ }
519
+
520
+ main().catch((err) => die(err.message || String(err)));
package/package.json ADDED
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "@blacksandscyber/mcp-server-bursar",
3
+ "version": "0.5.0",
4
+ "description": "Blacksands Bursar MCP Server — zero-trust security operations for AI agents. Installable in Claude Code (`claude mcp add shield -- shield-mcp`) and Claude Desktop (drag-and-drop .dxt).",
5
+ "main": "build/index.js",
6
+ "bin": {
7
+ "shield-mcp": "dxt/scripts/setup.js",
8
+ "blacksands-shield-mcp": "dxt/scripts/setup.js",
9
+ "blacksands-shield-mcp-http": "build/http-transport.js"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "build:dxt": "bash scripts/build-dxt.sh",
14
+ "start": "node build/index.js",
15
+ "start:http": "node build/http-transport.js",
16
+ "dev": "tsc --watch",
17
+ "test": "jest --forceExit --detectOpenHandles",
18
+ "lint": "tsc --noEmit",
19
+ "prepublishOnly": "tsc"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "blacksands",
24
+ "shield",
25
+ "zero-trust",
26
+ "security",
27
+ "ai-agent",
28
+ "claude",
29
+ "broker",
30
+ "mtls"
31
+ ],
32
+ "author": "Blacksands Cyber, Inc.",
33
+ "license": "MIT",
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "dependencies": {
38
+ "@aws-sdk/client-ssm": "^3.1032.0",
39
+ "@modelcontextprotocol/sdk": "^1.8.0",
40
+ "axios": "^1.7.0",
41
+ "glob": "^10.3.0",
42
+ "js-yaml": "^4.1.1",
43
+ "ssh2": "^1.17.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/adm-zip": "^0.5.8",
47
+ "@types/jest": "^29.5.0",
48
+ "@types/js-yaml": "^4.0.9",
49
+ "@types/node": "^20.0.0",
50
+ "@types/ssh2": "^1.15.5",
51
+ "adm-zip": "^0.5.17",
52
+ "ajv": "^8.20.0",
53
+ "ajv-formats": "^3.0.1",
54
+ "jest": "^29.7.0",
55
+ "ts-jest": "^29.1.0",
56
+ "typescript": "^5.3.0"
57
+ },
58
+ "engines": {
59
+ "node": ">=18.0.0"
60
+ },
61
+ "files": [
62
+ "build/*.js",
63
+ "build/*.d.ts",
64
+ "build/shared/**/*.js",
65
+ "build/shared/**/*.d.ts",
66
+ "build/shield/**/*.js",
67
+ "build/shield/**/*.d.ts",
68
+ "dxt/scripts/setup.js",
69
+ "dxt/blacksands-ca.crt",
70
+ "README.md",
71
+ "LICENSE",
72
+ "!build/dxt-stage/**",
73
+ "!**/*.tgz",
74
+ "!**/*.dxt"
75
+ ]
76
+ }