@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,902 @@
1
+ "use strict";
2
+ /**
3
+ * Blacksands Bursar MCP Server — 52 tools across 8 categories.
4
+ *
5
+ * The Shield MCP Server exposes the same operational surface a human
6
+ * administrator reaches through Overwatch or SysAdmin. Every call is
7
+ * authenticated and proxied through the full Broker handshake — there is
8
+ * no direct Shield API access, no API-key bootstrap, and no internal
9
+ * system access (Kafka, LDAP, AWS SDK, iptables, Route 53 are all internal
10
+ * to Blacksands and never touched by the Agent).
11
+ */
12
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ var desc = Object.getOwnPropertyDescriptor(m, k);
15
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
16
+ desc = { enumerable: true, get: function() { return m[k]; } };
17
+ }
18
+ Object.defineProperty(o, k2, desc);
19
+ }) : (function(o, m, k, k2) {
20
+ if (k2 === undefined) k2 = k;
21
+ o[k2] = m[k];
22
+ }));
23
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
24
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
25
+ }) : function(o, v) {
26
+ o["default"] = v;
27
+ });
28
+ var __importStar = (this && this.__importStar) || (function () {
29
+ var ownKeys = function(o) {
30
+ ownKeys = Object.getOwnPropertyNames || function (o) {
31
+ var ar = [];
32
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
33
+ return ar;
34
+ };
35
+ return ownKeys(o);
36
+ };
37
+ return function (mod) {
38
+ if (mod && mod.__esModule) return mod;
39
+ var result = {};
40
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
41
+ __setModuleDefault(result, mod);
42
+ return result;
43
+ };
44
+ })();
45
+ Object.defineProperty(exports, "__esModule", { value: true });
46
+ exports.buildAnonymizedScanSummary = buildAnonymizedScanSummary;
47
+ exports.createServer = createServer;
48
+ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
49
+ const zod_1 = require("zod");
50
+ const config_1 = require("./config");
51
+ const client_1 = require("./shield/client");
52
+ const logger_1 = require("./shared/logger");
53
+ const errors_1 = require("./shared/errors");
54
+ // Local codebase analysis (no Blacksands access required)
55
+ const frameworkDetector_1 = require("./shield/discovery/frameworkDetector");
56
+ const endpointScanner_1 = require("./shield/discovery/endpointScanner");
57
+ const piiDetector_1 = require("./shield/discovery/piiDetector");
58
+ const externalServiceDetector_1 = require("./shield/discovery/externalServiceDetector");
59
+ const dataStoreDetector_1 = require("./shield/discovery/dataStoreDetector");
60
+ const manifestGenerator_1 = require("./shield/discovery/manifestGenerator");
61
+ const severity_1 = require("./shield/discovery/severity");
62
+ const environmentScanner_1 = require("./shield/discovery/environmentScanner");
63
+ const dockerScanner_1 = require("./shield/discovery/dockerScanner");
64
+ const topologyNormalizer_1 = require("./shield/discovery/topologyNormalizer");
65
+ const postureReporter_1 = require("./shield/verify/postureReporter");
66
+ const orchestrator_1 = require("./shield/install/orchestrator");
67
+ const deploy_1 = require("./shield/deploy");
68
+ const protocol_walkthrough_1 = require("./shield/protocol-walkthrough");
69
+ const identity_1 = require("./shield/identity");
70
+ const bootRole_1 = require("./shield/bootRole");
71
+ const linkBuilder_1 = require("./shared/linkBuilder");
72
+ /** Helper to wrap tool handlers with error formatting. */
73
+ function safeHandler(fn) {
74
+ return fn()
75
+ .then(result => ({ content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }))
76
+ .catch(err => ({ content: [{ type: "text", text: (0, errors_1.formatError)(err) }] }));
77
+ }
78
+ /**
79
+ * Like safeHandler, but appends an optional one-line markdown footer (Shield
80
+ * Portal deep link, U1) after the JSON payload. `footerFn` receives the
81
+ * successful result; if it returns null or throws, the result is returned
82
+ * unchanged — deep links must never break a tool.
83
+ */
84
+ function safeHandlerWithFooter(fn, footerFn) {
85
+ return fn()
86
+ .then(async (result) => {
87
+ let footer = null;
88
+ try {
89
+ footer = await footerFn(result);
90
+ }
91
+ catch {
92
+ footer = null; // degrade gracefully — no link, no error
93
+ }
94
+ const text = JSON.stringify(result, null, 2);
95
+ return {
96
+ content: [{ type: "text", text: footer ? `${text}\n\n${footer}` : text }],
97
+ };
98
+ })
99
+ .catch(err => ({ content: [{ type: "text", text: (0, errors_1.formatError)(err) }] }));
100
+ }
101
+ /**
102
+ * Build the ANONYMIZED claim summary for a free local scan. Deliberately
103
+ * contains NO paths, hostnames, snippets, or service names — the Shield API
104
+ * rejects payloads that fail its anonymization schema, so we only ever send
105
+ * derived counts/severities and generic finding titles.
106
+ */
107
+ function buildAnonymizedScanSummary(scan) {
108
+ const score = (0, linkBuilder_1.previewScoreFromSeverity)(scan.bySeverity);
109
+ const findings = [];
110
+ for (const e of scan.endpoints ?? []) {
111
+ if (findings.length >= 5)
112
+ break;
113
+ if (e.auth_method === "unknown" || e.auth_method === "public") {
114
+ findings.push({
115
+ title: `${(e.method || "unknown").toUpperCase()} endpoint with no detectable authentication`,
116
+ severity: e.severity || "medium",
117
+ category: "auth",
118
+ recommendation: "Add authentication middleware or document why this route is public",
119
+ });
120
+ }
121
+ }
122
+ for (const p of scan.piiFields ?? []) {
123
+ if (findings.length >= 5)
124
+ break;
125
+ findings.push({
126
+ title: `Possible ${p.type || "PII"} field detected`,
127
+ severity: p.severity || "medium",
128
+ category: "pii",
129
+ recommendation: "Confirm the field and ensure encryption at rest and in transit",
130
+ });
131
+ }
132
+ return {
133
+ score,
134
+ highestSeverity: scan.highestSeverity,
135
+ bySeverity: scan.bySeverity,
136
+ framework: scan.framework
137
+ ? {
138
+ name: scan.framework.name,
139
+ language: scan.framework.language,
140
+ packageManager: scan.framework.packageManager,
141
+ }
142
+ : undefined,
143
+ counts: {
144
+ endpoints: scan.endpoints?.length ?? 0,
145
+ piiFields: scan.piiFields?.length ?? 0,
146
+ externalServices: scan.externalServices?.length ?? 0,
147
+ dataStores: scan.dataStores?.length ?? 0,
148
+ },
149
+ findings,
150
+ source: "mcp-scan",
151
+ };
152
+ }
153
+ function createServer() {
154
+ const config = (0, config_1.loadConfig)();
155
+ const server = new mcp_js_1.McpServer({
156
+ name: "blacksands-bursar",
157
+ version: "0.5.0",
158
+ });
159
+ // Resolve the cert's role at boot — drives which tools we register below.
160
+ // Synchronous on purpose: an async API call to /v1/mcp/identity would
161
+ // require the broker handshake to complete (~2-6s) before tool/list could
162
+ // respond, and Claude Desktop's initialize timeout is ~4-8s. So we resolve
163
+ // from a fast local source (env var → ~/.blacksands/mcp-certs/.role →
164
+ // mode-derived default) and rely on Shield API's requireMcpRole middleware
165
+ // for the authoritative defense-in-depth check at call time. A wrong
166
+ // answer here at worst shows the LLM extra tools; it can never grant
167
+ // extra capability because the API enforces independently.
168
+ const bootRole = (0, bootRole_1.resolveBootRole)(config.mode);
169
+ const canMaster = bootRole.role === "master";
170
+ logger_1.logger.info("MCP role resolved at boot", {
171
+ role: bootRole.role,
172
+ source: bootRole.source,
173
+ rationale: bootRole.rationale,
174
+ });
175
+ let shieldClient = null;
176
+ let shieldConnected = false;
177
+ async function getShield() {
178
+ if (!shieldClient) {
179
+ if (config.mode === "stdio") {
180
+ shieldClient = new client_1.ShieldClient({
181
+ clientCert: config.shield.mtls.clientCert,
182
+ clientKey: config.shield.mtls.clientKey,
183
+ caCert: config.shield.mtls.caCert ?? undefined,
184
+ broker: {
185
+ authorizerUrl: config.shield.broker.authorizerUrl,
186
+ serviceId: config.shield.broker.serviceId,
187
+ authPassword: config.shield.broker.authPassword,
188
+ },
189
+ });
190
+ }
191
+ else if (config.shield.service) {
192
+ shieldClient = new client_1.ShieldClient({
193
+ clientCert: config.shield.service.clientCert,
194
+ clientKey: config.shield.service.clientKey,
195
+ caCert: config.shield.service.caCert ?? undefined,
196
+ directUrl: config.shield.service.apiUrl,
197
+ });
198
+ }
199
+ else if (config.mode === "local-only") {
200
+ throw new Error("Shield API tools require a Blacksands account.\n\n" +
201
+ "You're running in local-only mode — these 12 tools work right now without an account:\n" +
202
+ " bursar_scan_codebase, bursar_scan_environment, bursar_detect_framework, bursar_scan_endpoints,\n" +
203
+ " bursar_flag_pii_candidates, bursar_detect_external_services, bursar_detect_data_stores,\n" +
204
+ " bursar_generate_manifest, bursar_get_protection_requirements,\n" +
205
+ " bursar_guide_deployment, broker_get_protocol_walkthrough,\n" +
206
+ " broker_get_my_identity\n\n" +
207
+ "To unlock provisioning, compliance, and Receiver tools, create a free account at:\n" +
208
+ " https://shield.blacksandscyber.online\n" +
209
+ "Your administrator can also issue you a setup token through Overwatch or Architect.");
210
+ }
211
+ else {
212
+ throw new Error("Shield API is not reachable from this MCP server instance. " +
213
+ "This server is running in http-service mode without a service mTLS certificate. " +
214
+ "To enable Shield-dependent tools, set SHIELD_API_URL, SHIELD_SERVICE_CERT, and SHIELD_SERVICE_KEY. " +
215
+ "Local codebase-scan tools (bursar_scan_codebase, bursar_flag_pii_candidates, etc.) work without Shield access.");
216
+ }
217
+ }
218
+ if (!shieldConnected) {
219
+ await shieldClient.connect();
220
+ shieldConnected = true;
221
+ }
222
+ return shieldClient;
223
+ }
224
+ // ═══════════════════════════════════════════════════════════════════════
225
+ // CATEGORY A: Shield Discovery (7 tools) — local codebase analysis
226
+ // ═══════════════════════════════════════════════════════════════════════
227
+ server.tool("bursar_scan_codebase", "[FREE] Full codebase security scan: detect framework, endpoints, PII, external services, data stores, and generate a security manifest — works on any local project, no Blacksands account needed", { projectPath: zod_1.z.string().describe("Absolute path to the project directory to scan") }, async ({ projectPath }) => safeHandlerWithFooter(async () => {
228
+ logger_1.logger.info("Scanning codebase", { projectPath });
229
+ const framework = await (0, frameworkDetector_1.detectFramework)(projectPath);
230
+ const endpoints = (0, severity_1.tagEndpoints)(await (0, endpointScanner_1.scanEndpoints)(projectPath, framework));
231
+ const piiFields = (0, severity_1.tagPiiFields)(await (0, piiDetector_1.detectPii)(projectPath));
232
+ const externalServices = await (0, externalServiceDetector_1.detectExternalServices)(projectPath);
233
+ const dataStores = await (0, dataStoreDetector_1.detectDataStores)(projectPath);
234
+ const manifest = (0, manifestGenerator_1.generateManifest)({ framework, endpoints, externalServices, dataStores, piiFields, projectPath });
235
+ const allFindings = [...endpoints, ...piiFields];
236
+ return {
237
+ bySeverity: (0, severity_1.summarizeBySeverity)(allFindings),
238
+ highestSeverity: (0, severity_1.topSeverity)(allFindings),
239
+ framework, endpoints, piiFields, externalServices, dataStores, manifest,
240
+ };
241
+ },
242
+ // U1 golden path: with no account, POST the anonymized summary to the
243
+ // public claim endpoint and link the Glass preview report. Org-bound
244
+ // installs deep-link to the Posture Home instead. Both degrade to "no
245
+ // footer" when offline/unreachable.
246
+ async (scan) => {
247
+ const summary = buildAnonymizedScanSummary(scan);
248
+ if (config.mode === "local-only") {
249
+ return (0, linkBuilder_1.buildClaimLine)(summary); // no account → claim URL (or null offline)
250
+ }
251
+ return (0, linkBuilder_1.portalHomeLine)(summary.score); // org-bound → Posture Home
252
+ }));
253
+ server.tool("bursar_scan_environment", "[FREE] Scan the local environment and return a normalized topology JSON (schemaVersion 1.0) for the visualization app. Phase 1 inspects the macOS host READ-ONLY: Apple Containerization `container` CLI (containers/networks/volumes), listening TCP ports, and host facts → the `infra` plane. The `zt` (zero-trust) plane is built ONLY from live Shield Broker state (receivers/sessions/endpoints) when an account is reachable — otherwise it is empty and metadata.trust.authoritative=false. No mutation; observe only.", {
254
+ provider: zod_1.z.enum(["macos", "docker", "aws", "azure"]).default("macos")
255
+ .describe("Environment provider to scan. 'macos' (Apple Containerization) and 'docker' (local Docker daemon, READ-ONLY) are implemented; aws/azure return an empty infra plane with a 'not yet implemented' note."),
256
+ planes: zod_1.z.array(zod_1.z.enum(["infra", "zt"])).default(["infra", "zt"])
257
+ .describe("Which planes to assemble: 'infra' (host/network/container/volume/service) and/or 'zt' (zero-trust control/data plane)."),
258
+ appId: zod_1.z.string().optional()
259
+ .describe("Optional Shield app id to scope ZT-plane enrichment (sessions/endpoints) to a single application."),
260
+ }, async ({ provider, planes, appId }) => safeHandler(async () => {
261
+ logger_1.logger.info("Scanning environment topology", { provider, planes, appId });
262
+ // ── infra plane ──────────────────────────────────────────────────────
263
+ let env = null;
264
+ const extraNotes = [];
265
+ if (provider === "macos") {
266
+ // Always inspect the host: it supplies the metadata.host name even when
267
+ // only the zt plane is requested, and the scan is cheap + read-only.
268
+ env = await (0, environmentScanner_1.scanMacEnvironment)();
269
+ }
270
+ else if (provider === "docker") {
271
+ // Phase 2: READ-ONLY Docker daemon inspection → same RawEnvironment shape,
272
+ // folded into the infra plane through the identical normalization path.
273
+ env = await (0, dockerScanner_1.scanDockerEnvironment)();
274
+ }
275
+ else {
276
+ // aws/azure: not yet implemented — empty infra plane + note (no throw).
277
+ extraNotes.push(`provider not yet implemented: ${provider}`);
278
+ }
279
+ // ── zt plane (live Broker state only) ─────────────────────────────────
280
+ let zt = null;
281
+ if (planes.includes("zt")) {
282
+ // Obtain a ZtSource adapter over the Shield client. If Shield access is
283
+ // not configured/reachable, degrade to an empty, non-authoritative
284
+ // plane — never throw, never fabricate trust.
285
+ let src = null;
286
+ try {
287
+ const client = await getShield();
288
+ src = {
289
+ orgId: config.shield.orgId,
290
+ listReceivers: (f) => client.listReceivers(f),
291
+ listApps: (orgId) => client.listApps(orgId),
292
+ listSessions: (id) => client.listSessions(id),
293
+ listEndpoints: (id) => client.listEndpoints(id),
294
+ };
295
+ }
296
+ catch (err) {
297
+ logger_1.logger.info("bursar_scan_environment: Shield client unavailable — ZT plane will be empty", { err: String(err) });
298
+ src = null;
299
+ }
300
+ zt = await (0, topologyNormalizer_1.buildZtPlane)(src, appId);
301
+ }
302
+ return (0, topologyNormalizer_1.assembleEnvelope)({ provider, planes, env, zt, extraNotes });
303
+ }));
304
+ server.tool("bursar_detect_framework", "[FREE] Detect application framework, runtime, and package manager from project files — no account needed", { projectPath: zod_1.z.string().describe("Path to project directory") }, async ({ projectPath }) => safeHandler(() => (0, frameworkDetector_1.detectFramework)(projectPath)));
305
+ server.tool("bursar_scan_endpoints", "[FREE] Scan source code for HTTP/API endpoints. Reports per-route HTTP method (never silently defaulted to GET), auth_method classification (public/session/jwt/signature/scheduler/token/api_key/oauth/unknown), file:line, and a 'routes with no detectable authentication' section — no account needed", { projectPath: zod_1.z.string().describe("Path to project directory") }, async ({ projectPath }) => safeHandler(async () => {
306
+ const fw = await (0, frameworkDetector_1.detectFramework)(projectPath);
307
+ const endpoints = (0, severity_1.tagEndpoints)(await (0, endpointScanner_1.scanEndpoints)(projectPath, fw));
308
+ const byMethod = {};
309
+ const byAuth = {};
310
+ for (const e of endpoints) {
311
+ byMethod[e.method] = (byMethod[e.method] ?? 0) + 1;
312
+ byAuth[e.auth_method] = (byAuth[e.auth_method] ?? 0) + 1;
313
+ }
314
+ const unauthenticated = endpoints.filter(e => e.auth_method === "unknown" || e.auth_method === "public");
315
+ const undeterminedMethod = endpoints.filter(e => e.method === "unknown");
316
+ return {
317
+ bySeverity: (0, severity_1.summarizeBySeverity)(endpoints),
318
+ highestSeverity: (0, severity_1.topSeverity)(endpoints),
319
+ endpoints,
320
+ summary: {
321
+ total: endpoints.length,
322
+ byMethod,
323
+ byAuth,
324
+ unauthenticated_or_unknown: unauthenticated.length,
325
+ method_undetermined: undeterminedMethod.length,
326
+ },
327
+ routes_with_no_detectable_authentication: unauthenticated.map(e => ({
328
+ method: e.method, path: e.path, file: e.file, line: e.line,
329
+ severity: e.severity, severity_rationale: e.severity_rationale,
330
+ })),
331
+ };
332
+ }));
333
+ server.tool("bursar_flag_pii_candidates", "[FREE] Scan source code and flag candidate PII fields (SSN, credit card, health data, etc.) for human review, with file:line, code snippet, confidence, severity, and remediation per finding. Comment/prose-only matches are filtered out to reduce false positives. Findings are candidates — not confirmed PII — and should be reviewed. Infers compliance requirements — no account needed", { projectPath: zod_1.z.string().describe("Path to project directory") }, async ({ projectPath }) => safeHandler(async () => {
334
+ const fields = (0, severity_1.tagPiiFields)(await (0, piiDetector_1.detectPii)(projectPath));
335
+ const { getSensitivityLevel, getComplianceHints } = await Promise.resolve().then(() => __importStar(require("./shield/discovery/piiDetector")));
336
+ const byConfidence = { high: 0, medium: 0, low: 0 };
337
+ const bySensitivity = { critical: 0, high: 0, medium: 0, low: 0 };
338
+ for (const f of fields) {
339
+ if (f.confidence)
340
+ byConfidence[f.confidence]++;
341
+ bySensitivity[f.sensitivity]++;
342
+ }
343
+ return {
344
+ bySeverity: (0, severity_1.summarizeBySeverity)(fields),
345
+ highestSeverity: (0, severity_1.topSeverity)(fields),
346
+ fields,
347
+ summary: {
348
+ total: fields.length,
349
+ byConfidence,
350
+ bySensitivity,
351
+ types: [...new Set(fields.map(f => f.type))],
352
+ },
353
+ sensitivityLevel: getSensitivityLevel(fields),
354
+ complianceHints: getComplianceHints(fields),
355
+ };
356
+ }));
357
+ server.tool("bursar_detect_external_services", "[FREE] Detect third-party service integrations (Stripe, OpenAI, AWS, Firebase, etc.) from dependencies and source code — no account needed", { projectPath: zod_1.z.string().describe("Path to project directory") }, async ({ projectPath }) => safeHandler(() => (0, externalServiceDetector_1.detectExternalServices)(projectPath)));
358
+ server.tool("bursar_detect_data_stores", "[FREE] Detect database connections (PostgreSQL, MongoDB, Redis, etc.) from dependencies, env vars, and Docker config — no account needed", { projectPath: zod_1.z.string().describe("Path to project directory") }, async ({ projectPath }) => safeHandler(() => (0, dataStoreDetector_1.detectDataStores)(projectPath)));
359
+ server.tool("bursar_generate_manifest", "[FREE] Generate a security manifest from discovery scan results or by scanning a project path — no account needed", { projectPath: zod_1.z.string().describe("Path to project directory") }, async ({ projectPath }) => safeHandler(async () => {
360
+ const framework = await (0, frameworkDetector_1.detectFramework)(projectPath);
361
+ const endpoints = await (0, endpointScanner_1.scanEndpoints)(projectPath, framework);
362
+ const piiFields = await (0, piiDetector_1.detectPii)(projectPath);
363
+ const externalServices = await (0, externalServiceDetector_1.detectExternalServices)(projectPath);
364
+ const dataStores = await (0, dataStoreDetector_1.detectDataStores)(projectPath);
365
+ return (0, manifestGenerator_1.generateManifest)({ framework, endpoints, externalServices, dataStores, piiFields, projectPath });
366
+ }));
367
+ // ─────────────────────────────────────────────────────────────────────────
368
+ // MASTER-ROLE-GATED TOOLS (Categories B–H, ~36 tools)
369
+ //
370
+ // Wrapped in `if (canMaster)` so a Consumer cert sees only the [FREE]
371
+ // tools registered above + Category I registered below. The block ends
372
+ // before "CATEGORY I: Local guidance" — that category contains [FREE]
373
+ // tools that consumers also get.
374
+ //
375
+ // Defense-in-depth: even if the boot role resolves wrong (env override,
376
+ // stale role hint file), the Shield API's requireMcpRole middleware
377
+ // independently 403s. Tool filtering here is a UX optimization (cleaner
378
+ // tools/list for the LLM); the API is the security boundary.
379
+ // ─────────────────────────────────────────────────────────────────────────
380
+ if (canMaster) {
381
+ // ═══════════════════════════════════════════════════════════════════════
382
+ // CATEGORY B: Shield Provisioning (7 tools)
383
+ // ═══════════════════════════════════════════════════════════════════════
384
+ server.tool("bursar_create_org", "Create a new organization in Blacksands Bursar", { name: zod_1.z.string(), plan: zod_1.z.string().optional() }, async ({ name, plan }) => safeHandler(async () => (await getShield()).createOrg(name, plan)));
385
+ server.tool("bursar_list_orgs", "List organizations in Blacksands Bursar", { page: zod_1.z.number().optional(), pageSize: zod_1.z.number().optional() }, async ({ page, pageSize }) => safeHandler(async () => (await getShield()).listOrgs(page, pageSize)));
386
+ server.tool("bursar_create_app", "Register a new application with an organization", {
387
+ orgId: zod_1.z.string(),
388
+ name: zod_1.z.string(),
389
+ // `type` is REQUIRED by the Shield API (POST /orgs/:orgId/apps validates
390
+ // body('type').isIn([...])). Default to 'web' so the common case works
391
+ // without the caller having to know the contract; override as needed.
392
+ type: zod_1.z.enum(["web", "mobile", "api", "iot", "service"]).default("web"),
393
+ framework: zod_1.z.string().optional(),
394
+ runtime: zod_1.z.string().optional(),
395
+ environment: zod_1.z.enum(["development", "staging", "production"]).optional(),
396
+ domain: zod_1.z.string().optional(),
397
+ }, async ({ orgId, name, type, framework, runtime, environment, domain }) => safeHandler(async () => (await getShield()).createApp(orgId, name, { type, framework, runtime, environment, domain })));
398
+ server.tool("bursar_list_apps", "List applications for an organization", { orgId: zod_1.z.string() }, async ({ orgId }) => safeHandler(async () => (await getShield()).listApps(orgId)));
399
+ server.tool("bursar_submit_manifest", "Submit a security manifest to Shield API for validation and provisioning", { manifest: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).describe("Security manifest JSON object"), orgId: zod_1.z.string() }, async ({ manifest, orgId }) => safeHandler(async () => (await getShield()).submitManifest(manifest, orgId)));
400
+ server.tool("bursar_provision_app", "Provision the full security stack (certs, DNS, policies, endpoints) from a submitted manifest", { manifestId: zod_1.z.string() }, async ({ manifestId }) => safeHandlerWithFooter(async () => {
401
+ const { operationId } = await (await getShield()).provisionManifest(manifestId);
402
+ const result = await (await getShield()).pollOperation(operationId);
403
+ return { manifestId, operationId, ...result };
404
+ },
405
+ // U1: deep link to live progress on the Posture Home.
406
+ () => (0, linkBuilder_1.portalHomeLine)(null)));
407
+ server.tool("bursar_poll_operation", "Check the status of an async Shield API operation", { operationId: zod_1.z.string() }, async ({ operationId }) => safeHandler(async () => (await getShield()).pollOperation(operationId)));
408
+ // ═══════════════════════════════════════════════════════════════════════
409
+ // CATEGORY C: mTLS Identity (2 tools — list + revoke, read-only surface)
410
+ // Issuance is performed by a human administrator through Overwatch or
411
+ // SysAdmin. The Agent can inventory and revoke, not create.
412
+ // ═══════════════════════════════════════════════════════════════════════
413
+ server.tool("bursar_list_mcp_certs", "List all active mTLS client certificates issued for this organization's MCP instances", {}, async () => safeHandler(async () => (await getShield()).listMcpCerts()));
414
+ server.tool("bursar_revoke_mcp_cert", "Revoke an mTLS client certificate by client name. Use this when decommissioning an MCP install or rotating credentials. To issue a new certificate, use Overwatch or SysAdmin.", { clientName: zod_1.z.string().describe("The client name used when the cert was issued") }, async ({ clientName }) => safeHandler(async () => (await getShield()).revokeMcpCert(clientName)));
415
+ // ═══════════════════════════════════════════════════════════════════════
416
+ // CATEGORY D: Verification & Compliance (4 tools)
417
+ // ═══════════════════════════════════════════════════════════════════════
418
+ server.tool("bursar_verify_posture", "Run a security posture verification scan on an application", {
419
+ appId: zod_1.z.string(),
420
+ // `scanType` is REQUIRED by the Shield API (POST /verify validates
421
+ // body('scanType').isIn([...])). Default to 'full' so the tool works
422
+ // without the caller knowing the contract; override for a lighter scan.
423
+ scanType: zod_1.z.enum(["full", "quick", "compliance", "penetration"]).default("full"),
424
+ }, async ({ appId, scanType }) => safeHandler(async () => {
425
+ const { operationId } = await (await getShield()).triggerVerify(appId, scanType);
426
+ const op = await (await getShield()).pollOperation(operationId);
427
+ return (0, postureReporter_1.formatPosture)(op.result);
428
+ }));
429
+ server.tool("bursar_get_posture", "Get the latest security posture score, grade, and recommendations for an application", { appId: zod_1.z.string() }, async ({ appId }) => safeHandlerWithFooter(async () => {
430
+ const result = await (await getShield()).getLatestPosture(appId);
431
+ return (0, postureReporter_1.formatPosture)(result);
432
+ },
433
+ // U1: append "Posture preview: NN/100 — view full report → portal".
434
+ (posture) => {
435
+ const score = typeof posture?.score === "number" ? posture.score
436
+ : typeof posture?.posture?.score === "number" ? posture.posture.score
437
+ : null;
438
+ return (0, linkBuilder_1.portalHomeLine)(score);
439
+ }));
440
+ server.tool("bursar_compliance_report", "Generate a compliance report (SOC2, HIPAA, PCI-DSS, ISO27001) for an application", { appId: zod_1.z.string(), framework: zod_1.z.enum(["SOC2", "HIPAA", "PCI-DSS", "ISO27001"]) }, async ({ appId, framework }) => safeHandlerWithFooter(async () => (await getShield()).getComplianceReport(appId, framework),
441
+ // U1: compliance reports render richer in the portal — deep link it.
442
+ () => (0, linkBuilder_1.portalHomeLine)(null)));
443
+ server.tool("bursar_compliance_controls", "List all compliance controls for a given framework", { framework: zod_1.z.enum(["SOC2", "HIPAA", "PCI-DSS", "ISO27001"]) }, async ({ framework }) => safeHandler(async () => (await getShield()).getComplianceControls(framework)));
444
+ // ═══════════════════════════════════════════════════════════════════════
445
+ // CATEGORY E: Security Operations (10 tools)
446
+ // ═══════════════════════════════════════════════════════════════════════
447
+ server.tool("bursar_emergency_lockdown", "Emergency lockdown: immediately revoke all certificates and sessions for an application", { appId: zod_1.z.string(), reason: zod_1.z.string() }, async ({ appId, reason }) => safeHandler(async () => (await getShield()).emergencyLockdown(appId, reason)));
448
+ server.tool("bursar_lift_lockdown", "Lift an emergency lockdown on an application", { appId: zod_1.z.string() }, async ({ appId }) => safeHandler(async () => (await getShield()).liftLockdown(appId)));
449
+ server.tool("bursar_list_certs", "List all certificates for an application", { appId: zod_1.z.string() }, async ({ appId }) => safeHandler(async () => (await getShield()).listCerts(appId)));
450
+ server.tool("bursar_rotate_cert", "Rotate a specific certificate", { appId: zod_1.z.string(), certId: zod_1.z.string() }, async ({ appId, certId }) => safeHandler(async () => (await getShield()).rotateCert(appId, certId)));
451
+ server.tool("bursar_revoke_cert", "Revoke a specific certificate", { appId: zod_1.z.string(), certId: zod_1.z.string(), reason: zod_1.z.string().optional() }, async ({ appId, certId, reason }) => safeHandler(async () => (await getShield()).revokeCert(appId, certId, reason)));
452
+ server.tool("bursar_list_policies", "List security policies for an application", { appId: zod_1.z.string() }, async ({ appId }) => safeHandler(async () => (await getShield()).listPolicies(appId)));
453
+ server.tool("bursar_create_policy", "Create a security policy (network, DNS, access, or compliance) for an application", { appId: zod_1.z.string(), name: zod_1.z.string(), type: zod_1.z.string(), rules: zod_1.z.array(zod_1.z.object({ action: zod_1.z.enum(["allow", "deny", "log"]), target: zod_1.z.string() })) }, async ({ appId, name, type, rules }) => safeHandler(async () => (await getShield()).createPolicy(appId, name, type, rules)));
454
+ server.tool("bursar_update_dns_rules", "Add or remove application-level DNS allow/block rules (not Receiver DNS, which is auto-managed)", { appId: zod_1.z.string(), rules: zod_1.z.array(zod_1.z.object({ domain: zod_1.z.string(), action: zod_1.z.enum(["allow", "block"]), reason: zod_1.z.string().optional() })) }, async ({ appId, rules }) => safeHandler(async () => (await getShield()).updateDnsRules(appId, rules)));
455
+ server.tool("bursar_list_sessions", "List active sessions for an application", { appId: zod_1.z.string() }, async ({ appId }) => safeHandler(async () => (await getShield()).listSessions(appId)));
456
+ server.tool("bursar_revoke_session", "Revoke a specific session", { appId: zod_1.z.string(), sessionId: zod_1.z.string() }, async ({ appId, sessionId }) => safeHandler(async () => (await getShield()).revokeSession(appId, sessionId)));
457
+ // ═══════════════════════════════════════════════════════════════════════
458
+ // CATEGORY F: Receiver Lifecycle (13 tools — initialization + services + service lifecycle + monitoring)
459
+ // Networking inputs only. DNS is auto-assigned by Blacksands during
460
+ // Receiver registration and is read-only from the Agent's perspective.
461
+ // ═══════════════════════════════════════════════════════════════════════
462
+ server.tool("receiver_initialize", "Start Receiver initialization: creates the Receiver record and sends installation credentials to the installer email. IMPORTANT: This requires a Linux server where you can run Docker (e.g., an EC2 instance, VPS, or cloud VM). If the user does not have a server yet, help them deploy one first (Railway Pro, Render, DigitalOcean, or AWS EC2).", { installerEmail: zod_1.z.string(), organizationId: zod_1.z.string(), notes: zod_1.z.string().optional() }, async ({ installerEmail, organizationId, notes }) => safeHandler(async () => (await getShield()).initializeReceiver(installerEmail, organizationId, notes)));
463
+ server.tool("receiver_init_status", "Check the initialization status of a Receiver (awaiting, in_progress, completed, failed)", { receiverUID: zod_1.z.string() }, async ({ receiverUID }) => safeHandler(async () => (await getShield()).getReceiverStatus(receiverUID)));
464
+ server.tool("receiver_activate", "Activate a Receiver after initialization completes", { receiverUID: zod_1.z.string() }, async ({ receiverUID }) => safeHandler(async () => (await getShield()).activateReceiver(receiverUID)));
465
+ server.tool("receiver_resend_email", "Resend installation credentials email for a pending Receiver initialization", { receiverUID: zod_1.z.string() }, async ({ receiverUID }) => safeHandler(async () => (await getShield()).resendReceiverEmail(receiverUID)));
466
+ server.tool("receiver_cancel_init", "Cancel a pending Receiver initialization", { receiverUID: zod_1.z.string() }, async ({ receiverUID }) => safeHandler(async () => (await getShield()).cancelReceiverInit(receiverUID)));
467
+ server.tool("receiver_onboard_service", "Add a service to be proxied by the Receiver. Supply networking inputs only: name, backend host, port, and protocol. Blacksands handles the proxy wiring, certificates, and firewall internally.", {
468
+ receiverUID: zod_1.z.string(),
469
+ name: zod_1.z.string().describe("Service name"),
470
+ host: zod_1.z.string().describe("Backend host or IP"),
471
+ port: zod_1.z.number().describe("Backend port"),
472
+ protocol: zod_1.z.enum(["http", "https", "ssh", "rdp"]),
473
+ path: zod_1.z.string().optional(),
474
+ }, async (params) => safeHandler(async () => (await getShield()).onboardReceiverService(params.receiverUID, {
475
+ name: params.name, host: params.host, port: params.port, protocol: params.protocol, path: params.path,
476
+ })));
477
+ server.tool("receiver_remove_service", "Remove a proxied service from a Receiver", { receiverUID: zod_1.z.string(), serviceName: zod_1.z.string() }, async ({ receiverUID, serviceName }) => safeHandler(async () => (await getShield()).removeReceiverService(receiverUID, serviceName)));
478
+ server.tool("receiver_list_services", "List all services currently proxied by a Receiver", { receiverUID: zod_1.z.string() }, async ({ receiverUID }) => safeHandler(async () => (await getShield()).listReceiverServices(receiverUID)));
479
+ // ── Service lifecycle (F4.12) ───────────────────────────────────────────
480
+ // Control a proxied service's protection state without deleting it.
481
+ // pause = reversible stop (new connections denied, registration kept);
482
+ // resume = paused→active; disable = stronger off-state requiring admin
483
+ // re-enable; status = current state + health. To delete a service entirely,
484
+ // use receiver_remove_service instead.
485
+ server.tool("bursar_service_pause", "Pause a proxied service: temporarily stop accepting new connections while keeping its registration. Reversible with bursar_service_resume. Use this for maintenance windows or to quickly cut off a service without removing it.", { receiverUID: zod_1.z.string(), serviceName: zod_1.z.string().describe("Service name as shown by receiver_list_services") }, async ({ receiverUID, serviceName }) => safeHandler(async () => (await getShield()).pauseService(receiverUID, serviceName)));
486
+ server.tool("bursar_service_resume", "Resume a paused service, returning it to active so it accepts connections again.", { receiverUID: zod_1.z.string(), serviceName: zod_1.z.string().describe("Service name as shown by receiver_list_services") }, async ({ receiverUID, serviceName }) => safeHandler(async () => (await getShield()).resumeService(receiverUID, serviceName)));
487
+ server.tool("bursar_service_disable", "Disable a proxied service: take it offline in a state that requires an explicit re-enable (stronger than pause). The registration is kept — use receiver_remove_service to delete it entirely.", { receiverUID: zod_1.z.string(), serviceName: zod_1.z.string().describe("Service name as shown by receiver_list_services") }, async ({ receiverUID, serviceName }) => safeHandler(async () => (await getShield()).disableService(receiverUID, serviceName)));
488
+ server.tool("bursar_service_status", "Get the current protection state (active / paused / disabled) and health of a proxied service.", { receiverUID: zod_1.z.string(), serviceName: zod_1.z.string().describe("Service name as shown by receiver_list_services") }, async ({ receiverUID, serviceName }) => safeHandler(async () => (await getShield()).getServiceStatus(receiverUID, serviceName)));
489
+ server.tool("receiver_get_dns", "View the DNS name(s) automatically assigned to a Receiver by Blacksands. Read-only — DNS is provisioned during Receiver registration and cannot be modified by the Agent.", { receiverUID: zod_1.z.string() }, async ({ receiverUID }) => safeHandler(async () => {
490
+ const record = await (await getShield()).getReceiver(receiverUID);
491
+ return {
492
+ receiverUID,
493
+ dnsName: record.dnsName ?? null,
494
+ wildcardDomain: record.wildcardDomain ?? null,
495
+ publicIP: record.publicIP ?? null,
496
+ note: "DNS records are auto-provisioned by Blacksands during Receiver registration. To change them, the Receiver must be re-registered with new networking inputs.",
497
+ };
498
+ }));
499
+ // ═══════════════════════════════════════════════════════════════════════
500
+ // CATEGORY G: Receiver Monitoring (3 tools)
501
+ // ═══════════════════════════════════════════════════════════════════════
502
+ server.tool("receiver_health", "Get the health status of a specific Receiver", { receiverUID: zod_1.z.string() }, async ({ receiverUID }) => safeHandler(async () => (await getShield()).getReceiverStatus(receiverUID)));
503
+ server.tool("receiver_list", "List all Receivers with optional status and organization filters", { organizationId: zod_1.z.string().optional(), status: zod_1.z.string().optional() }, async ({ organizationId, status }) => safeHandler(async () => (await getShield()).listReceivers({ organizationId, status })));
504
+ server.tool("receiver_statistics", "Get Receiver initialization and deployment statistics", {}, async () => safeHandler(async () => (await getShield()).getReceiverStatistics()));
505
+ // ═══════════════════════════════════════════════════════════════════════
506
+ // CATEGORY H: Composite provisioning (1 tool)
507
+ // `bursar_install_agent_remotely` issues a new MCP identity, delivers it
508
+ // to the target via the chosen transport, writes the agent config, and
509
+ // verifies the broker handshake — closing the "manual copy-files" seam
510
+ // in multi-agent provisioning. See SHIELD-INSTALL-AGENT-REMOTELY-
511
+ // REQUIREMENTS.md for the full contract.
512
+ //
513
+ // Phase A.1: bootstrap-token transport only; SSH and aws-ssm stub-and-
514
+ // throw. Config-merge supports claude-desktop + mcp-server JSON.
515
+ // ═══════════════════════════════════════════════════════════════════════
516
+ server.tool("bursar_install_agent_remotely", "Provision and install a new Blacksands Bursar AI-agent identity on a remote host. " +
517
+ "Issues an mTLS client certificate tied to `clientName`, delivers the credential bundle " +
518
+ "to the target via the selected transport, writes the agent's MCP config, and verifies " +
519
+ "the agent completes the Authorizer → Receiver broker handshake before returning. " +
520
+ "Intended as the one-call replacement for the manual cert-issue → copy-files → " +
521
+ "edit-config → restart workflow. Phase A.1 supports transport.type='bootstrap-token' " +
522
+ "fully; 'ssh' and 'aws-ssm' are stubs that will throw a 'not-implemented' InstallError.", {
523
+ // Identity
524
+ clientName: zod_1.z.string()
525
+ .regex(/^[a-z0-9][a-z0-9-]{1,62}$/, "must be 2-63 chars, lowercase alphanumeric + hyphens, not starting with a hyphen")
526
+ .describe("Unique name for the new agent instance — becomes the cert CN."),
527
+ orgId: zod_1.z.string()
528
+ .describe("Organization the new identity will belong to."),
529
+ // Agent runtime on target
530
+ agentType: zod_1.z.enum(["claude-desktop", "mcp-server", "openclaw", "custom"])
531
+ .describe("Which MCP agent runtime the target will use. Determines config path + restart command."),
532
+ configPath: zod_1.z.string().optional()
533
+ .describe("Absolute path on target for MCP config. Required when agentType=custom; defaulted for known types."),
534
+ restartCmd: zod_1.z.string().optional()
535
+ .describe("Shell command to restart the agent on the target. Defaults are inferred from agentType where possible."),
536
+ // Delivery transport (discriminated union)
537
+ transport: zod_1.z.discriminatedUnion("type", [
538
+ zod_1.z.object({
539
+ type: zod_1.z.literal("ssh"),
540
+ host: zod_1.z.string(),
541
+ port: zod_1.z.number().int().min(1).max(65535).default(22),
542
+ username: zod_1.z.string(),
543
+ authMethod: zod_1.z.enum(["private-key", "ssh-agent", "password"]),
544
+ privateKeyPath: zod_1.z.string().optional(),
545
+ knownHostsPath: zod_1.z.string().optional(),
546
+ hostKeyFingerprint: zod_1.z.string().optional()
547
+ .describe("Expected SSH host-key SHA256 fingerprint (strict check). RECOMMENDED."),
548
+ sudo: zod_1.z.boolean().default(false),
549
+ }),
550
+ zod_1.z.object({
551
+ type: zod_1.z.literal("bootstrap-token"),
552
+ ttlSeconds: zod_1.z.number().int().min(60).max(3600).default(900)
553
+ .describe("How long the setup token remains valid, in seconds. Max 1 hour."),
554
+ allowedCidr: zod_1.z.string().optional()
555
+ .describe("If set, redemption is restricted to this CIDR (NOT YET ENFORCED — Phase B)."),
556
+ oneShot: zod_1.z.boolean().default(true)
557
+ .describe("Token is single-use. Currently always true."),
558
+ deliverVia: zod_1.z.enum(["return", "email"]).default("return"),
559
+ email: zod_1.z.string().email().optional(),
560
+ }),
561
+ zod_1.z.object({
562
+ type: zod_1.z.literal("aws-ssm"),
563
+ instanceId: zod_1.z.string(),
564
+ region: zod_1.z.string().optional(),
565
+ }),
566
+ ]),
567
+ // Broker config on target
568
+ serviceId: zod_1.z.string().default("shield-api")
569
+ .describe("Shield service ID the target will connect to through the Broker."),
570
+ authorizerUrl: zod_1.z.string().url().optional()
571
+ .describe("Defaults to the Authorizer configured on this MCP server."),
572
+ // RBAC role for the new cert. 'master' = full 47-tool surface (default,
573
+ // backward compat). 'consumer' = restricted 11-tool [FREE]-only surface;
574
+ // appropriate for developer/CI agents that need to call protected
575
+ // services through the Broker but should NOT be able to manage Shield
576
+ // itself. Enforced at TWO layers: Shield API requireMcpRole middleware
577
+ // (per-route 403 on admin endpoints) AND MCP-server-side resolveBootRole
578
+ // (filters tool registration so the LLM doesn't even see admin tools).
579
+ role: zod_1.z.enum(["master", "consumer"]).optional()
580
+ .describe("RBAC role. Defaults to 'master' for backward compat. Use 'consumer' for least-privilege agents that only need [FREE] tools + broker access to protected services."),
581
+ // Behavior
582
+ dryRun: zod_1.z.boolean().default(false)
583
+ .describe("Plan the install without touching the target or issuing credentials."),
584
+ waitForHandshake: zod_1.z.boolean().default(true)
585
+ .describe("Poll for the target's first broker handshake before returning. Phase A.1 no-ops this — endpoint not yet deployed."),
586
+ handshakeTimeoutMs: zod_1.z.number().int().min(5000).max(300_000).default(60_000),
587
+ rollbackOnFailure: zod_1.z.boolean().default(true)
588
+ .describe("Revoke credentials and undo file writes if any post-issue phase fails."),
589
+ }, async (params) => safeHandler(async () => {
590
+ const input = {
591
+ clientName: params.clientName,
592
+ orgId: params.orgId,
593
+ agentType: params.agentType,
594
+ configPath: params.configPath,
595
+ restartCmd: params.restartCmd,
596
+ transport: params.transport,
597
+ serviceId: params.serviceId,
598
+ authorizerUrl: params.authorizerUrl,
599
+ role: params.role,
600
+ dryRun: params.dryRun,
601
+ waitForHandshake: params.waitForHandshake,
602
+ handshakeTimeoutMs: params.handshakeTimeoutMs,
603
+ rollbackOnFailure: params.rollbackOnFailure,
604
+ };
605
+ return (0, orchestrator_1.runInstallWithRollback)(input, {
606
+ shield: await getShield(),
607
+ defaultAuthorizerUrl: config.mode === "stdio" ? config.shield.broker?.authorizerUrl : undefined,
608
+ });
609
+ }));
610
+ } // end if (canMaster) — Categories B–H gated to master role only
611
+ // ═══════════════════════════════════════════════════════════════════════
612
+ // CATEGORY I: Local guidance ([FREE] — both roles)
613
+ // ═══════════════════════════════════════════════════════════════════════
614
+ server.tool("bursar_get_protection_requirements", "[FREE] Given a security manifest (or a project path to scan), explain in plain English what is needed to protect this app with Blacksands Bursar — including what infrastructure the user must set up first. No account needed.", {
615
+ manifest: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()).optional()
616
+ .describe("Security manifest JSON from bursar_generate_manifest. If omitted, provide projectPath instead."),
617
+ projectPath: zod_1.z.string().optional()
618
+ .describe("Path to the project directory to scan. Used when manifest is not yet generated."),
619
+ }, async ({ manifest, projectPath }) => safeHandler(async () => {
620
+ let m = manifest ?? {};
621
+ // If no manifest provided, generate one on the fly
622
+ if (!manifest && projectPath) {
623
+ const fw = await (0, frameworkDetector_1.detectFramework)(projectPath);
624
+ const ep = await (0, endpointScanner_1.scanEndpoints)(projectPath, fw);
625
+ const pii = await (0, piiDetector_1.detectPii)(projectPath);
626
+ const ext = await (0, externalServiceDetector_1.detectExternalServices)(projectPath);
627
+ const ds = await (0, dataStoreDetector_1.detectDataStores)(projectPath);
628
+ m = (0, manifestGenerator_1.generateManifest)({ framework: fw, endpoints: ep, externalServices: ext, dataStores: ds, piiFields: pii, projectPath });
629
+ }
630
+ const requirements = [];
631
+ // 1. Deployed backend — needed if there are API endpoints to protect
632
+ const endpoints = m.endpoints ?? [];
633
+ if (endpoints.length > 0) {
634
+ requirements.push({
635
+ status: "required_before_receiver",
636
+ item: "Deployed backend server with Blacksands Receiver alongside",
637
+ description: `Your app has ${endpoints.length} API endpoint(s). The simplest path for non-coders: ` +
638
+ "a single DigitalOcean Droplet running both your app and a hardened Blacksands Receiver " +
639
+ "via docker-compose. One VPS, about $6/month, ~20 minutes to a protected endpoint. " +
640
+ "Call the bursar_guide_deployment tool with target='digitalocean-droplet' to get a " +
641
+ "step-by-step walkthrough plus a ready-to-paste docker-compose.yml. " +
642
+ "Advanced users can also deploy the app separately (Railway/Render/EC2) and run the " +
643
+ "Receiver on their own infrastructure.",
644
+ helpLink: "https://shield.blacksandscyber.online/docs/deploy",
645
+ });
646
+ }
647
+ // 2. Blacksands account — needed for all provisioning tools
648
+ requirements.push({
649
+ status: "required_for_shield_tools",
650
+ item: "Blacksands Bursar account",
651
+ description: "Create a free account at shield.blacksandscyber.online. Your administrator will " +
652
+ "generate a one-click install link that adds your Shield identity to Claude Desktop. " +
653
+ "Once installed, you can provision your app with bursar_create_app and bursar_provision_app.",
654
+ helpLink: "https://shield.blacksandscyber.online",
655
+ });
656
+ // 3. Compliance note for PII-heavy apps
657
+ const piiFields = m.piiFields ?? [];
658
+ if (piiFields.length > 0) {
659
+ requirements.push({
660
+ status: "recommended",
661
+ item: "Compliance framework review",
662
+ description: `Your app handles ${piiFields.length} PII field type(s). After provisioning, ` +
663
+ "use bursar_compliance_report to generate a SOC 2 or HIPAA report. " +
664
+ "This is optional but recommended before going to production.",
665
+ });
666
+ }
667
+ const nextStep = endpoints.length > 0
668
+ ? "Deploy your app first (ask Claude to help), then create a Blacksands account to protect it."
669
+ : "Create a free Blacksands account at shield.blacksandscyber.online to start provisioning.";
670
+ return {
671
+ summary: "What you need before protecting this app with Blacksands Bursar",
672
+ requirements,
673
+ nextStep,
674
+ localToolsAvailable: [
675
+ "bursar_scan_codebase", "bursar_detect_framework", "bursar_scan_endpoints",
676
+ "bursar_flag_pii_candidates", "bursar_detect_external_services", "bursar_detect_data_stores",
677
+ "bursar_generate_manifest",
678
+ ],
679
+ note: "The tools listed above work right now, no account required. Provisioning and protection tools unlock after you create an account.",
680
+ };
681
+ }));
682
+ server.tool("bursar_guide_deployment", "[FREE] **CALL THIS BEFORE ANSWERING ANY QUESTION ABOUT DEPLOYING / HOSTING / RUNNING AN APP WITH BLACKSANDS.** " +
683
+ "This is the AUTHORITATIVE source for Blacksands Bursar's deployment architecture. " +
684
+ "Do not improvise from general zero-trust or proxy knowledge — Blacksands is NOT like " +
685
+ "Cloudflare Tunnel, Tailscale Funnel, ngrok, or any outbound-tunneling agent. The Blacksands " +
686
+ "Receiver is a public-IP edge proxy that requires inbound TCP 443 and consumes Kafka messages " +
687
+ "from the Manager (no HTTP to the Authorizer). Call this tool with no `target` first to get " +
688
+ "the architecture overview + a list of supported targets, then call it again with a specific " +
689
+ "target for copy-paste steps. Available targets: " +
690
+ "'digitalocean-droplet' (single-VPS production, ~$6/mo, ~20 min) | " +
691
+ "'local-docker-development' (LAPTOP DEV ONLY — no Receiver; laptops can't host one). " +
692
+ "No Blacksands account needed to read the output.", {
693
+ target: zod_1.z.enum(["digitalocean-droplet", "local-docker-development"]).optional().describe("Deployment target. **Omit on first call** to get an architecture overview + target list. " +
694
+ "Then call again with the chosen target for copy-paste steps. " +
695
+ "'digitalocean-droplet' = production-ready single-VPS with hardened Receiver. " +
696
+ "'local-docker-development' = laptop dev only, no Receiver (laptops lack the public IP a Receiver requires)."),
697
+ appImage: zod_1.z.string().optional().describe("Docker image tag for the user's app (e.g. 'myapp:latest'). If omitted, the " +
698
+ "walkthrough uses a placeholder the user fills in."),
699
+ appPort: zod_1.z.number().optional().describe("Port the app listens on inside its container. Default: 8080."),
700
+ appName: zod_1.z.string().optional().describe("Short slug for the app — used in container and host names. Default: 'my-app'."),
701
+ receiverSetupToken: zod_1.z.string().optional().describe("Setup token from receiver_initialize. Can be omitted; the walkthrough will " +
702
+ "instruct the user to paste it later."),
703
+ region: zod_1.z.string().optional().describe("DigitalOcean region slug (e.g. 'nyc3', 'sfo3'). Default: 'nyc3'. Only relevant for digitalocean-droplet."),
704
+ dropletSize: zod_1.z.string().optional().describe("DigitalOcean droplet size slug (e.g. 's-1vcpu-2gb'). Default: 's-1vcpu-2gb'. Only relevant for digitalocean-droplet."),
705
+ }, async (params) => safeHandler(async () => (0, deploy_1.getDeploymentGuide)(params.target ?? null, params)));
706
+ server.tool("broker_get_protocol_walkthrough", "[FREE] **CALL THIS BEFORE WRITING ANY CODE OR INSTRUCTIONS FOR ACCESSING A BLACKSANDS-PROTECTED SERVICE.** " +
707
+ "This is the AUTHORITATIVE description of the bisected Blacksands broker auth protocol: agent → Authorizer (mTLS) → service-list → " +
708
+ "Receiver (mTLS again, independently re-verified) → backend service. Do NOT improvise from general mTLS or zero-trust knowledge — " +
709
+ "this protocol is unusual: the cert is presented at TWO independent stages with the same identity, and the backend service URL is " +
710
+ "NEVER reachable directly. Returns: bisectedFlow steps, falseModelsToReject (anti-patterns to NOT generate), copy-paste code examples " +
711
+ "in Node.js/Python/curl, and common pitfalls with fixes. Optionally pass language='node'|'python'|'curl' to scope the examples. " +
712
+ "No Blacksands account needed to read the output.", {
713
+ language: zod_1.z.enum(["node", "python", "curl", "all"]).optional().describe("Filter code examples to a specific language. Default: 'all' (returns Node, Python, and curl)."),
714
+ }, async (params) => safeHandler(async () => (0, protocol_walkthrough_1.getProtocolWalkthrough)({ language: params.language })));
715
+ server.tool("broker_get_my_identity", "[FREE] Returns the calling MCP cert's identity: CN, RBAC role (master | consumer), org id, " +
716
+ "client name, and tier. Useful for the LLM to introspect 'who am I, what role do I have, what " +
717
+ "org am I authorized for' before generating code that talks to Blacksands-protected services. " +
718
+ "In local-only mode (no Shield account) returns a synthetic { role: 'consumer' } identity. " +
719
+ "Includes a protocol_hint pointing at broker_get_protocol_walkthrough — agents calling this " +
720
+ "naturally encounter the next breadcrumb to learn the bisected auth flow.", {}, async () => safeHandler(async () => {
721
+ let identity;
722
+ if (config.mode === "stdio") {
723
+ try {
724
+ const shield = await getShield();
725
+ identity = await (0, identity_1.fetchClientIdentity)(shield);
726
+ }
727
+ catch (err) {
728
+ // Don't propagate broker failures from this introspection tool —
729
+ // surface them as part of the response so the LLM can reason about
730
+ // them rather than seeing a generic error.
731
+ identity = {
732
+ ...(0, identity_1.localOnlyIdentity)(),
733
+ cn: null,
734
+ role: "consumer",
735
+ };
736
+ return {
737
+ ...identity,
738
+ protocol_hint: "To make requests through your assigned services, call broker_get_protocol_walkthrough " +
739
+ "first for the protocol details (Authorizer mTLS → service-list → Receiver mTLS → backend).",
740
+ warning: `Could not fetch identity from Shield API: ${err.message}. ` +
741
+ `Falling back to consumer-mode synthetic identity.`,
742
+ };
743
+ }
744
+ }
745
+ else {
746
+ identity = (0, identity_1.localOnlyIdentity)();
747
+ }
748
+ return {
749
+ ...identity,
750
+ protocol_hint: "To make requests through your assigned services, call broker_get_protocol_walkthrough " +
751
+ "first for the protocol details (Authorizer mTLS → service-list → Receiver mTLS → backend).",
752
+ };
753
+ }));
754
+ // ═══════════════════════════════════════════════════════════════════════
755
+ // Welcome resource — surfaced by Claude Desktop when the extension is active
756
+ // ═══════════════════════════════════════════════════════════════════════
757
+ server.resource?.("shield-getting-started", "blacksands://getting-started", { mimeType: "text/markdown" }, async () => ({
758
+ contents: [{
759
+ uri: "blacksands://getting-started",
760
+ mimeType: "text/markdown",
761
+ text: [
762
+ "# Blacksands Bursar — Start Here",
763
+ "",
764
+ "## You Can Start Right Now (No Account Needed)",
765
+ "",
766
+ "Seven tools work on any local codebase without a Blacksands account:",
767
+ "",
768
+ "| Tool | What it does |",
769
+ "|---|---|",
770
+ "| `bursar_scan_codebase` | Full scan in one call — runs all 6 tools below |",
771
+ "| `bursar_detect_framework` | Identifies framework, runtime, and package manager |",
772
+ "| `bursar_scan_endpoints` | Finds all API routes and their auth methods |",
773
+ "| `bursar_flag_pii_candidates` | Flags candidate sensitive fields (SSN, credit card, health data) for review |",
774
+ "| `bursar_detect_external_services` | Detects Stripe, OpenAI, AWS, Twilio, etc. |",
775
+ "| `bursar_detect_data_stores` | Finds PostgreSQL, MongoDB, Redis, ORMs |",
776
+ "| `bursar_generate_manifest` | Produces a Security Manifest JSON |",
777
+ "",
778
+ "**Try it now:** Ask Claude — *\"Scan my project at ~/my-app with Blacksands Bursar\"*",
779
+ "",
780
+ "## Ready to Protect a Deployed App?",
781
+ "",
782
+ "Once your app is running on a server:",
783
+ "",
784
+ "1. Create a free account at **shield.blacksandscyber.online**",
785
+ "2. Your administrator sends you a one-click install link",
786
+ "3. Click it — your Shield identity installs automatically",
787
+ "4. Ask Claude: *\"Register my app and set up a Receiver proxy\"*",
788
+ "",
789
+ "Shield provisions mTLS certificates, a secure Receiver proxy, and security",
790
+ "policies — all without touching your code.",
791
+ "",
792
+ "## Not sure what you need?",
793
+ "",
794
+ "Ask Claude: *\"What do I need to protect my app?\"* and it will run",
795
+ "`bursar_get_protection_requirements` to give you a plain-English checklist.",
796
+ "",
797
+ "## Ready to deploy? (non-coder path)",
798
+ "",
799
+ "Ask Claude: *\"Walk me through deploying my app with Blacksands on a single VPS\"*",
800
+ "and it will run `bursar_guide_deployment` to return a complete step-by-step",
801
+ "guide plus a hardened `docker-compose.yml` — app + Receiver on one DigitalOcean",
802
+ "Droplet, ~$6/month, about 20 minutes end-to-end.",
803
+ "",
804
+ "## Note for assistants: deployment questions",
805
+ "",
806
+ "**Always call `bursar_guide_deployment` BEFORE answering any Blacksands deployment",
807
+ "question.** Do not improvise from general zero-trust knowledge — Blacksands does not",
808
+ "follow the Cloudflare-Tunnel / Tailscale-Funnel / ngrok / outbound-agent model. The",
809
+ "Receiver is a public-IP edge proxy that requires inbound TCP 443 and consumes Kafka",
810
+ "messages from the Manager (it does NOT speak HTTP to the Authorizer). Calling the tool",
811
+ "with no `target` returns the authoritative architecture overview plus a list of",
812
+ "supported targets — start there, then call again with a specific target for steps.",
813
+ "",
814
+ "## Note for assistants: writing code that calls Blacksands-protected services",
815
+ "",
816
+ "**Always call `broker_get_protocol_walkthrough` BEFORE writing any code or instructions",
817
+ "for accessing a Blacksands-protected service.** The auth chain is BISECTED — agent does",
818
+ "mTLS to the Authorizer, gets a service list, then does a SECOND independently-verified",
819
+ "mTLS handshake to the Receiver (same cert, two stages). This pattern is unusual and",
820
+ "easily confused with simpler single-stage mTLS designs (Cloudflare Access, Okta, etc.).",
821
+ "Read the tool's bisectedFlow + falseModelsToReject sections before generating any code.",
822
+ ].join("\n"),
823
+ }],
824
+ }));
825
+ // Broker protocol walkthrough — same content as broker_get_protocol_walkthrough
826
+ // tool, surfaced via the resources API for clients that prime context at session
827
+ // start (Claude Desktop does this with the getting-started resource above).
828
+ // Single source of truth: src/shield/protocol-walkthrough.ts.
829
+ server.resource?.("shield-broker-protocol", "blacksands://broker-protocol", { mimeType: "text/markdown" }, async () => {
830
+ const w = (0, protocol_walkthrough_1.getProtocolWalkthrough)({ language: "all" });
831
+ const lines = [
832
+ "# Blacksands Broker Auth Protocol — Authoritative Reference",
833
+ "",
834
+ `> ${w.authoritativeNote}`,
835
+ "",
836
+ "## Summary",
837
+ "",
838
+ w.summary,
839
+ "",
840
+ "## The Bisected Flow",
841
+ "",
842
+ ];
843
+ for (const step of w.bisectedFlow) {
844
+ lines.push(`### Step ${step.step} — ${step.actor}`);
845
+ lines.push("");
846
+ lines.push(`**Action:** ${step.action}`);
847
+ lines.push("");
848
+ lines.push(`**Crypto check:** ${step.cryptoCheck}`);
849
+ lines.push("");
850
+ lines.push(`**What goes wrong if skipped:** ${step.whatGoesWrongIfSkipped}`);
851
+ lines.push("");
852
+ }
853
+ lines.push("## False Models to Reject");
854
+ lines.push("");
855
+ for (const m of w.falseModelsToReject)
856
+ lines.push(`- ${m}`);
857
+ lines.push("");
858
+ lines.push("## Code Examples");
859
+ lines.push("");
860
+ for (const ex of w.codeExamples) {
861
+ lines.push(`### ${ex.filename} (${ex.language})`);
862
+ lines.push("");
863
+ lines.push(ex.notes);
864
+ lines.push("");
865
+ lines.push("```" + ex.language);
866
+ lines.push(ex.code);
867
+ lines.push("```");
868
+ lines.push("");
869
+ }
870
+ lines.push("## Common Pitfalls");
871
+ lines.push("");
872
+ for (const p of w.commonPitfalls) {
873
+ lines.push(`**Symptom:** ${p.symptom}`);
874
+ lines.push(`- Cause: ${p.cause}`);
875
+ lines.push(`- Fix: ${p.fix}`);
876
+ lines.push("");
877
+ }
878
+ return {
879
+ contents: [{
880
+ uri: "blacksands://broker-protocol",
881
+ mimeType: "text/markdown",
882
+ text: lines.join("\n"),
883
+ }],
884
+ };
885
+ });
886
+ // Tool count depends on resolved boot role: master sees all 52, consumer
887
+ // sees 12 ([FREE] tools only). Real number reflected in tools/list.
888
+ const toolCount = canMaster ? 52 : 12;
889
+ logger_1.logger.info(`Blacksands Bursar MCP Server initialized with ${toolCount} tools (role=${bootRole.role})`, {
890
+ mode: config.mode,
891
+ role: bootRole.role,
892
+ roleSource: bootRole.source,
893
+ orgId: config.shield.orgId,
894
+ shieldReachable: config.mode === "stdio" ? "via broker" : config.mode === "local-only" ? "unavailable (local-only mode — 11 free [FREE] tools active)" : (config.shield.service ? "via service mTLS" : "unavailable (local tools only)"),
895
+ ...(config.mode === "stdio" ? {
896
+ authorizer: config.shield.broker.authorizerUrl,
897
+ serviceId: config.shield.broker.serviceId,
898
+ } : {}),
899
+ });
900
+ return server;
901
+ }
902
+ //# sourceMappingURL=server.js.map