@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.
- package/README.md +230 -0
- package/build/config.d.ts +45 -0
- package/build/config.js +177 -0
- package/build/http-transport.d.ts +16 -0
- package/build/http-transport.js +191 -0
- package/build/index.d.ts +16 -0
- package/build/index.js +31 -0
- package/build/server.d.ts +41 -0
- package/build/server.js +902 -0
- package/build/shared/errors.d.ts +50 -0
- package/build/shared/errors.js +69 -0
- package/build/shared/linkBuilder.d.ts +93 -0
- package/build/shared/linkBuilder.js +148 -0
- package/build/shared/logger.d.ts +10 -0
- package/build/shared/logger.js +28 -0
- package/build/shield/bootRole.d.ts +60 -0
- package/build/shield/bootRole.js +145 -0
- package/build/shield/client.d.ts +265 -0
- package/build/shield/client.js +656 -0
- package/build/shield/deploy/index.d.ts +69 -0
- package/build/shield/deploy/index.js +569 -0
- package/build/shield/discovery/dataStoreDetector.d.ts +3 -0
- package/build/shield/discovery/dataStoreDetector.js +125 -0
- package/build/shield/discovery/dockerScanner.d.ts +34 -0
- package/build/shield/discovery/dockerScanner.js +543 -0
- package/build/shield/discovery/endpointScanner.d.ts +3 -0
- package/build/shield/discovery/endpointScanner.js +306 -0
- package/build/shield/discovery/environmentScanner.d.ts +86 -0
- package/build/shield/discovery/environmentScanner.js +545 -0
- package/build/shield/discovery/externalServiceDetector.d.ts +3 -0
- package/build/shield/discovery/externalServiceDetector.js +98 -0
- package/build/shield/discovery/frameworkDetector.d.ts +3 -0
- package/build/shield/discovery/frameworkDetector.js +114 -0
- package/build/shield/discovery/manifestGenerator.d.ts +12 -0
- package/build/shield/discovery/manifestGenerator.js +124 -0
- package/build/shield/discovery/piiDetector.d.ts +5 -0
- package/build/shield/discovery/piiDetector.js +203 -0
- package/build/shield/discovery/severity.d.ts +47 -0
- package/build/shield/discovery/severity.js +138 -0
- package/build/shield/discovery/topologyNormalizer.d.ts +109 -0
- package/build/shield/discovery/topologyNormalizer.js +416 -0
- package/build/shield/identity.d.ts +53 -0
- package/build/shield/identity.js +70 -0
- package/build/shield/install/configMerge.d.ts +91 -0
- package/build/shield/install/configMerge.js +324 -0
- package/build/shield/install/keystore.d.ts +25 -0
- package/build/shield/install/keystore.js +156 -0
- package/build/shield/install/orchestrator.d.ts +33 -0
- package/build/shield/install/orchestrator.js +404 -0
- package/build/shield/install/transports/awsSsm.d.ts +43 -0
- package/build/shield/install/transports/awsSsm.js +378 -0
- package/build/shield/install/transports/bootstrapToken.d.ts +39 -0
- package/build/shield/install/transports/bootstrapToken.js +117 -0
- package/build/shield/install/transports/ssh.d.ts +50 -0
- package/build/shield/install/transports/ssh.js +569 -0
- package/build/shield/install/types.d.ts +139 -0
- package/build/shield/install/types.js +10 -0
- package/build/shield/protocol-walkthrough.d.ts +65 -0
- package/build/shield/protocol-walkthrough.js +392 -0
- package/build/shield/provision/appProvisioner.d.ts +15 -0
- package/build/shield/provision/appProvisioner.js +25 -0
- package/build/shield/types.d.ts +261 -0
- package/build/shield/types.js +4 -0
- package/build/shield/verify/postureReporter.d.ts +4 -0
- package/build/shield/verify/postureReporter.js +31 -0
- package/dxt/blacksands-ca.crt +67 -0
- package/dxt/scripts/setup.js +520 -0
- 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
|
+
}
|