@privacyscrubber/mcp-server 1.0.5 → 1.6.8

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/index.js CHANGED
@@ -24,6 +24,11 @@ const require = createRequire(import.meta.url);
24
24
  const __filename = fileURLToPath(import.meta.url);
25
25
  const __dirname = path.dirname(__filename);
26
26
 
27
+ // Version is sourced from mcp-server/package.json.
28
+ // build.js syncs mcp-server/package.json from root package.json on every build.
29
+ // Never hardcode the version — bump package.json at root instead.
30
+ const MCP_VERSION = require('./package.json').version;
31
+
27
32
  // Import the production core engine with 100% parity
28
33
  const scrubberCorePath = path.resolve(__dirname, './scrubber-core.cjs');
29
34
  const PrivacyScrubberCore = require(scrubberCorePath);
@@ -34,6 +39,24 @@ PrivacyScrubberCore.init();
34
39
  // Volatile in-memory token map
35
40
  const sessionMap = {};
36
41
 
42
+ // ANSI terminal color helpers
43
+ const colors = {
44
+ yellowBold: '\x1b[1;33m',
45
+ yellow: '\x1b[33m',
46
+ cyan: '\x1b[36m',
47
+ redBold: '\x1b[1;31m',
48
+ reset: '\x1b[0m'
49
+ };
50
+
51
+ // Secrets detection patterns — defined once here, shared by detectSecrets() and performSanitization()
52
+ const DEVOPS_SECRETS_DETECTOR = [
53
+ { name: 'AWS Credentials', regex: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA)[A-Z0-9]{16}\b/g },
54
+ { name: 'JSON Web Token (JWT)', regex: /\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/g },
55
+ { name: 'API Token/Key (GitHub/Slack/NPM)', regex: /\b(?:ghp|gho|ghu|ghs|ghr|glpat|npm|xox[baprs])[-_][A-Za-z0-9_]{10,}\b/g },
56
+ { name: 'Stripe API Key', regex: /\b(?:[rs]k)_(?:test|live)_[a-zA-Z0-9]{24,}\b/g },
57
+ { name: 'Database/API Secret', regex: /\b(DB|POSTGRES|REDIS|MYSQL|AWS|SECRET|PASSWORD|TOKEN|API|KEY)[A-Z0-9_]*\s*[:=]\s*[^ \t\r\n"']{8,}\b/gi }
58
+ ];
59
+
37
60
  // Hardcoded public key for offline cryptographic verification
38
61
  const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
39
62
  MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw3f37srO402PU4++Baf8
@@ -80,11 +103,44 @@ function checkLicenseStatus() {
80
103
  }
81
104
  }
82
105
 
106
+ // ── Session request counter (volatile in-memory) ─────────────────────────────
107
+ let sessionRequestCount = 0;
108
+ const UPSELL_EVERY_N = 10; // show upsell nudge every N free-tier requests
109
+
110
+ // Build a clean upsell content block visible to the agent / user
111
+ function buildUpsellBlock(reason) {
112
+ const count = sessionRequestCount;
113
+ let lines = [
114
+ '─────────────────────────────────────────────',
115
+ '⚡ PrivacyScrubber PRO — Upgrade your AI setup',
116
+ '─────────────────────────────────────────────',
117
+ ];
118
+ if (reason) lines.push(`ℹ️ ${reason}`);
119
+ lines.push(
120
+ `📊 Free session requests: ${count}`,
121
+ '🔒 PRO unlocks: 22 industry profiles (Dev, Medical, Legal…)',
122
+ ' + Custom regex rules (privacyscrubber.json)',
123
+ ' + Unlimited input size',
124
+ '',
125
+ '💳 PRO Lifetime: $110 → https://privacyscrubber.com/pricing',
126
+ '',
127
+ 'After purchase, add your key to your MCP config:',
128
+ ' "PRIVACYSCRUBBER_KEY": "<your-key-here>"',
129
+ '─────────────────────────────────────────────'
130
+ );
131
+ return { type: 'text', text: lines.join('\n') };
132
+ }
133
+
134
+ // Decide if we should attach a soft periodic nudge (every N requests, free tier)
135
+ function shouldNudge(isPro) {
136
+ return !isPro && sessionRequestCount > 0 && sessionRequestCount % UPSELL_EVERY_N === 0;
137
+ }
138
+
83
139
  // Create the MCP server
84
140
  const server = new Server(
85
141
  {
86
142
  name: "privacyscrubber/pii-masking-mcp",
87
- version: "1.0.4",
143
+ version: MCP_VERSION,
88
144
  },
89
145
  {
90
146
  capabilities: {
@@ -146,6 +202,24 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
146
202
  },
147
203
  required: ["file_path"]
148
204
  }
205
+ },
206
+ {
207
+ name: "create_default_config",
208
+ description: "Creates a default 'privacyscrubber.json' configuration file in the active workspace root directory if one does not exist. Includes template structures for custom regex rules and exclusion bypass patterns.",
209
+ inputSchema: {
210
+ type: "object",
211
+ properties: {},
212
+ required: []
213
+ }
214
+ },
215
+ {
216
+ name: "check_status",
217
+ description: "Returns the current PrivacyScrubber MCP tier, session usage, available profiles, and PRO upgrade instructions. Call this to see your license status or get setup help.",
218
+ inputSchema: {
219
+ type: "object",
220
+ properties: {},
221
+ required: []
222
+ }
149
223
  }
150
224
  ]
151
225
  };
@@ -157,41 +231,89 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
157
231
 
158
232
  try {
159
233
  if (name === "sanitize_text") {
160
- const { text, profile = "General" } = args;
161
- const targetProfile = profile.trim();
234
+ const { text, profile = "General" } = args || {};
235
+ if (text === undefined || text === null) {
236
+ return {
237
+ isError: true,
238
+ content: [{ type: "text", text: "Error: Missing required parameter 'text'. Provide the string to sanitize." }]
239
+ };
240
+ }
241
+ if (typeof text !== "string") {
242
+ return {
243
+ isError: true,
244
+ content: [{ type: "text", text: `Error: Parameter 'text' must be a string, got '${typeof text}'. Stringify objects before passing.` }]
245
+ };
246
+ }
247
+ const targetProfile = (profile || "General").trim();
162
248
 
163
249
  // Check tier gating for advanced profiles
164
250
  const isAdvanced = targetProfile.toLowerCase() !== "general";
165
251
  const license = checkLicenseStatus();
252
+ let finalProfile = targetProfile;
253
+ const extraBlocks = [];
254
+
255
+ sessionRequestCount++;
256
+
166
257
  if (isAdvanced && !license.isPro) {
167
258
  const reason = license.error ? ` (${license.error})` : "";
168
- return {
169
- content: [
170
- {
171
- type: "text",
172
- text: `⚠️ [PrivacyScrubber] Advanced profile '${targetProfile}' is locked in the FREE tier. Falling back to 'General' profile.${reason}\nTo unlock 22+ advanced industry profiles, custom regex, and unlimited logs protection, set PRIVACYSCRUBBER_KEY to your PRO license key.\nGet a key at: https://privacyscrubber.com/pricing\nBrowser Extension: https://chromewebstore.google.com/detail/privacyscrubber-%E2%80%94-pii-red/pimoejgefeilajmmbpghifdmhdlkgjol\n\n--- Sanitized Output (General Profile) ---\n`
173
- },
174
- {
175
- type: "text",
176
- text: performSanitization(text, "General")
177
- }
178
- ]
179
- };
259
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] Advanced profile '${targetProfile}' is locked in the FREE tier. Falling back to 'General' profile.${reason}${colors.reset}\n${colors.cyan}👉 Get a PRO key at: https://privacyscrubber.com/pricing${colors.reset}\n`);
260
+ finalProfile = "General";
261
+ extraBlocks.push(buildUpsellBlock(`Profile '${targetProfile}' requires PRO. Using 'General' as fallback.`));
262
+ }
263
+
264
+ const { processedText, wasTruncated } = truncateIfFree(text, license.isPro);
265
+ if (wasTruncated) {
266
+ extraBlocks.push(buildUpsellBlock(`Input was truncated to 50,000 characters (Free Tier limit).`));
267
+ }
268
+
269
+ if (!license.isPro) {
270
+ const detected = detectSecrets(processedText);
271
+ if (detected.length > 0) {
272
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).${colors.reset}\n${colors.cyan}👉 Upgrade at: https://privacyscrubber.com/pricing${colors.reset}\n`);
273
+ extraBlocks.push(buildUpsellBlock(`API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).`));
274
+ }
275
+ }
276
+
277
+ const sanitized = performSanitization(processedText, finalProfile);
278
+
279
+ // Periodic soft nudge every N free-tier requests (no trigger event needed)
280
+ if (!license.isPro && extraBlocks.length === 0 && sessionRequestCount % UPSELL_EVERY_N === 0) {
281
+ extraBlocks.push(buildUpsellBlock(null));
180
282
  }
181
283
 
182
- const sanitized = performSanitization(text, targetProfile);
183
284
  return {
184
285
  content: [
185
- {
186
- type: "text",
187
- text: sanitized
188
- }
286
+ { type: "text", text: sanitized },
287
+ ...extraBlocks
189
288
  ]
190
289
  };
191
290
  }
192
291
 
193
292
  if (name === "reveal_text") {
194
- const { text } = args;
293
+ const { text } = args || {};
294
+ if (text === undefined || text === null) {
295
+ return {
296
+ isError: true,
297
+ content: [{ type: "text", text: "Error: Missing required parameter 'text'. Provide the AI response containing placeholders to restore." }]
298
+ };
299
+ }
300
+ if (typeof text !== "string") {
301
+ return {
302
+ isError: true,
303
+ content: [{ type: "text", text: `Error: Parameter 'text' must be a string, got '${typeof text}'.` }]
304
+ };
305
+ }
306
+
307
+ // Warn early if session has no tokens — reveal would be a no-op
308
+ if (Object.keys(sessionMap).length === 0) {
309
+ return {
310
+ content: [{
311
+ type: "text",
312
+ text: `⚠️ Session map is empty — no tokens to restore. Call 'sanitize_text' or 'sanitize_file' first to build the token map, then pass the AI's response here.`
313
+ }]
314
+ };
315
+ }
316
+
195
317
  const restored = PrivacyScrubberCore.unscrubText(text, sessionMap);
196
318
  return {
197
319
  content: [
@@ -268,39 +390,177 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
268
390
  const isAdvanced = targetProfile.toLowerCase() !== "general";
269
391
  const license = checkLicenseStatus();
270
392
 
271
- let prefix = "";
272
393
  let finalProfile = targetProfile;
394
+ const extraBlocks = [];
395
+
396
+ sessionRequestCount++;
273
397
 
274
398
  if (isAdvanced && !license.isPro) {
275
399
  const reason = license.error ? ` (${license.error})` : "";
276
- prefix = `⚠️ [PrivacyScrubber] Advanced profile '${targetProfile}' is locked in the FREE tier. Falling back to 'General' profile.${reason}\nGet a PRO key at: https://privacyscrubber.com/pricing\nBrowser Extension: https://chromewebstore.google.com/detail/privacyscrubber-%E2%80%94-pii-red/pimoejgefeilajmmbpghifdmhdlkgjol\n\n`;
400
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] Advanced profile '${targetProfile}' is locked in the FREE tier. Falling back to 'General' profile.${reason}${colors.reset}\n${colors.cyan}👉 Get a PRO key at: https://privacyscrubber.com/pricing${colors.reset}\n`);
277
401
  finalProfile = "General";
402
+ extraBlocks.push(buildUpsellBlock(`Profile '${targetProfile}' requires PRO. Using 'General' as fallback.`));
403
+ }
404
+
405
+ const { processedText: processedContent, wasTruncated } = truncateIfFree(content, license.isPro);
406
+ if (wasTruncated) extraBlocks.push(buildUpsellBlock(`File content was truncated to 50,000 characters (Free Tier limit).`));
407
+
408
+ if (!license.isPro) {
409
+ const detected = detectSecrets(processedContent);
410
+ if (detected.length > 0) {
411
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).${colors.reset}\n${colors.cyan}👉 Upgrade at: https://privacyscrubber.com/pricing${colors.reset}\n`);
412
+ extraBlocks.push(buildUpsellBlock(`API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).`));
413
+ }
414
+ }
415
+
416
+ const sanitized = performSanitization(processedContent, finalProfile);
417
+
418
+ if (!license.isPro && extraBlocks.length === 0 && sessionRequestCount % UPSELL_EVERY_N === 0) {
419
+ extraBlocks.push(buildUpsellBlock(null));
278
420
  }
279
421
 
280
- const sanitized = performSanitization(content, finalProfile);
281
422
  return {
282
423
  content: [
283
- {
284
- type: "text",
285
- text: `${prefix}${sanitized}`
286
- }
424
+ { type: "text", text: sanitized },
425
+ ...extraBlocks
287
426
  ]
288
427
  };
289
428
  } catch (docxError) {
429
+ return {
430
+ isError: true,
431
+ content: [{ type: "text", text: `Error: Failed to parse DOCX file: ${docxError.message}` }]
432
+ };
433
+ }
434
+ }
435
+
436
+ const buffer = fs.readFileSync(resolvedPath);
437
+
438
+ const isPdf = resolvedPath.toLowerCase().endsWith(".pdf");
439
+ const isExcel = resolvedPath.toLowerCase().endsWith(".xlsx") || resolvedPath.toLowerCase().endsWith(".xls");
440
+
441
+ if (isPdf || isExcel) {
442
+ const license = checkLicenseStatus();
443
+ if (!license.isPro) {
290
444
  return {
291
445
  isError: true,
292
446
  content: [
293
447
  {
294
448
  type: "text",
295
- text: `Error: Failed to parse DOCX file: ${docxError.message}`
449
+ text: `Error: PDF and Excel file sanitization is a PRO feature. Set PRIVACYSCRUBBER_KEY to your PRO license key to unlock local document parsing. Get a key at: https://privacyscrubber.com/pricing`
296
450
  }
297
451
  ]
298
452
  };
299
453
  }
454
+
455
+ if (isPdf) {
456
+ try {
457
+ const pdf = require('pdf-parse');
458
+ const data = await pdf(buffer);
459
+ const content = data.text;
460
+ const targetProfile = profile.trim();
461
+ const isAdvanced = targetProfile.toLowerCase() !== "general";
462
+
463
+ let finalProfile = targetProfile;
464
+ const extraBlocks = [];
465
+
466
+ sessionRequestCount++;
467
+
468
+ if (isAdvanced && !license.isPro) {
469
+ const reason = license.error ? ` (${license.error})` : "";
470
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] Advanced profile '${targetProfile}' is locked in the FREE tier. Falling back to 'General' profile.${reason}${colors.reset}\n${colors.cyan}👉 Get a PRO key at: https://privacyscrubber.com/pricing${colors.reset}\n`);
471
+ finalProfile = "General";
472
+ extraBlocks.push(buildUpsellBlock(`Profile '${targetProfile}' requires PRO. Using 'General' as fallback.`));
473
+ }
474
+
475
+ const { processedText: processedContent, wasTruncated } = truncateIfFree(content, license.isPro);
476
+ if (wasTruncated) extraBlocks.push(buildUpsellBlock(`File content was truncated to 50,000 characters (Free Tier limit).`));
477
+
478
+ if (!license.isPro) {
479
+ const detected = detectSecrets(processedContent);
480
+ if (detected.length > 0) {
481
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).${colors.reset}\n${colors.cyan}👉 Upgrade at: https://privacyscrubber.com/pricing${colors.reset}\n`);
482
+ extraBlocks.push(buildUpsellBlock(`API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).`));
483
+ }
484
+ }
485
+
486
+ const sanitized = performSanitization(processedContent, finalProfile);
487
+
488
+ if (!license.isPro && extraBlocks.length === 0 && sessionRequestCount % UPSELL_EVERY_N === 0) {
489
+ extraBlocks.push(buildUpsellBlock(null));
490
+ }
491
+
492
+ return {
493
+ content: [
494
+ { type: "text", text: sanitized },
495
+ ...extraBlocks
496
+ ]
497
+ };
498
+ } catch (pdfError) {
499
+ return {
500
+ isError: true,
501
+ content: [{ type: "text", text: `Error: Failed to parse PDF file: ${pdfError.message}` }]
502
+ };
503
+ }
504
+ }
505
+
506
+ if (isExcel) {
507
+ try {
508
+ const XLSX = require('xlsx');
509
+ const workbook = XLSX.read(buffer, { type: 'buffer' });
510
+ let content = '';
511
+ workbook.SheetNames.forEach(sheetName => {
512
+ const worksheet = workbook.Sheets[sheetName];
513
+ content += `--- Sheet: ${sheetName} ---\n`;
514
+ content += XLSX.utils.sheet_to_csv(worksheet) + '\n';
515
+ });
516
+
517
+ const targetProfile = profile.trim();
518
+ const isAdvanced = targetProfile.toLowerCase() !== "general";
519
+
520
+ let finalProfile = targetProfile;
521
+ const extraBlocks = [];
522
+
523
+ sessionRequestCount++;
524
+
525
+ if (isAdvanced && !license.isPro) {
526
+ const reason = license.error ? ` (${license.error})` : "";
527
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] Advanced profile '${targetProfile}' is locked in the FREE tier. Falling back to 'General' profile.${reason}${colors.reset}\n${colors.cyan}👉 Get a PRO key at: https://privacyscrubber.com/pricing${colors.reset}\n`);
528
+ finalProfile = "General";
529
+ extraBlocks.push(buildUpsellBlock(`Profile '${targetProfile}' requires PRO. Using 'General' as fallback.`));
530
+ }
531
+
532
+ const { processedText: processedContent, wasTruncated } = truncateIfFree(content, license.isPro);
533
+ if (wasTruncated) extraBlocks.push(buildUpsellBlock(`File content was truncated to 50,000 characters (Free Tier limit).`));
534
+
535
+ if (!license.isPro) {
536
+ const detected = detectSecrets(processedContent);
537
+ if (detected.length > 0) {
538
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).${colors.reset}\n${colors.cyan}👉 Upgrade at: https://privacyscrubber.com/pricing${colors.reset}\n`);
539
+ extraBlocks.push(buildUpsellBlock(`API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).`));
540
+ }
541
+ }
542
+
543
+ const sanitized = performSanitization(processedContent, finalProfile);
544
+
545
+ if (!license.isPro && extraBlocks.length === 0 && sessionRequestCount % UPSELL_EVERY_N === 0) {
546
+ extraBlocks.push(buildUpsellBlock(null));
547
+ }
548
+
549
+ return {
550
+ content: [
551
+ { type: "text", text: sanitized },
552
+ ...extraBlocks
553
+ ]
554
+ };
555
+ } catch (xlsxError) {
556
+ return {
557
+ isError: true,
558
+ content: [{ type: "text", text: `Error: Failed to parse Excel file: ${xlsxError.message}` }]
559
+ };
560
+ }
561
+ }
300
562
  }
301
563
 
302
- const buffer = fs.readFileSync(resolvedPath);
303
-
304
564
  // Check for null bytes in the first 8KB to detect binary files
305
565
  let isBinary = false;
306
566
  const checkLen = Math.min(buffer.length, 8000);
@@ -317,7 +577,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
317
577
  content: [
318
578
  {
319
579
  type: "text",
320
- text: `Error: Binary file format detected. The local MCP server only supports plain text files (e.g., source code, logs, CSV, markdown, JSON). To sanitize complex formats like PDF or DOCX, please use the web interface at: https://privacyscrubber.com/`
580
+ text: `Error: Binary file format detected. The local MCP server only supports plain text files (e.g., source code, logs, CSV, markdown, JSON) and premium document formats (PDF, DOCX, XLSX). To sanitize PDF or Excel files, please upgrade to PRO or use the web interface.`
321
581
  }
322
582
  ]
323
583
  };
@@ -328,52 +588,278 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
328
588
  const isAdvanced = targetProfile.toLowerCase() !== "general";
329
589
  const license = checkLicenseStatus();
330
590
 
331
- let prefix = "";
332
591
  let finalProfile = targetProfile;
592
+ const extraBlocks = [];
593
+
594
+ sessionRequestCount++;
333
595
 
334
596
  if (isAdvanced && !license.isPro) {
335
597
  const reason = license.error ? ` (${license.error})` : "";
336
- prefix = `⚠️ [PrivacyScrubber] Advanced profile '${targetProfile}' is locked in the FREE tier. Falling back to 'General' profile.${reason}\nGet a PRO key at: https://privacyscrubber.com/pricing\nBrowser Extension: https://chromewebstore.google.com/detail/privacyscrubber-%E2%80%94-pii-red/pimoejgefeilajmmbpghifdmhdlkgjol\n\n`;
598
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] Advanced profile '${targetProfile}' is locked in the FREE tier. Falling back to 'General' profile.${reason}${colors.reset}\n${colors.cyan}👉 Get a PRO key at: https://privacyscrubber.com/pricing${colors.reset}\n`);
337
599
  finalProfile = "General";
600
+ extraBlocks.push(buildUpsellBlock(`Profile '${targetProfile}' requires PRO. Using 'General' as fallback.`));
601
+ }
602
+
603
+ const { processedText: processedContent2, wasTruncated: wasTruncated2 } = truncateIfFree(content, license.isPro);
604
+ if (wasTruncated2) extraBlocks.push(buildUpsellBlock(`File content was truncated to 50,000 characters (Free Tier limit).`));
605
+
606
+ if (!license.isPro) {
607
+ const detected = detectSecrets(processedContent2);
608
+ if (detected.length > 0) {
609
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).${colors.reset}\n${colors.cyan}👉 Upgrade at: https://privacyscrubber.com/pricing${colors.reset}\n`);
610
+ extraBlocks.push(buildUpsellBlock(`API Key / Secret (${detected.join(', ')}) detected! Sanitization skipped (Requires PRO Profile).`));
611
+ }
612
+ }
613
+
614
+ const sanitized = performSanitization(processedContent2, finalProfile);
615
+
616
+ if (!license.isPro && extraBlocks.length === 0 && sessionRequestCount % UPSELL_EVERY_N === 0) {
617
+ extraBlocks.push(buildUpsellBlock(null));
338
618
  }
339
619
 
340
- const sanitized = performSanitization(content, finalProfile);
341
620
  return {
342
621
  content: [
343
- {
622
+ { type: "text", text: sanitized },
623
+ ...extraBlocks
624
+ ]
625
+ };
626
+ }
627
+
628
+ if (name === "create_default_config") {
629
+ const configPath = path.resolve(process.cwd(), 'privacyscrubber.json');
630
+ if (fs.existsSync(configPath)) {
631
+ return {
632
+ content: [{
344
633
  type: "text",
345
- text: `${prefix}${sanitized}`
634
+ text: `ℹ️ Configuration file 'privacyscrubber.json' already exists in the active workspace root directory:\n ${configPath}\nNo changes were made.`
635
+ }]
636
+ };
637
+ }
638
+
639
+ const defaultTemplate = {
640
+ "_comment": "PrivacyScrubber local configuration. Add custom rules and restart your MCP client (Cursor/Windsurf). Details: https://privacyscrubber.com/docs/config",
641
+ "customRules": [
642
+ {
643
+ "pattern": "my-secret-pattern-\\d+",
644
+ "label": "CUSTOM_TAG",
645
+ "_comment": "pattern must be a valid Javascript regex string. label is the tag replacement (e.g. CUSTOM_TAG)"
346
646
  }
347
647
  ]
348
648
  };
649
+
650
+ try {
651
+ fs.writeFileSync(configPath, JSON.stringify(defaultTemplate, null, 2) + '\n', 'utf8');
652
+ return {
653
+ content: [{
654
+ type: "text",
655
+ text: `✅ Configuration file 'privacyscrubber.json' successfully created in your workspace root:\n ${configPath}\n\nRestart your MCP client (e.g. Cursor or Claude Desktop) to load the config rules.`
656
+ }]
657
+ };
658
+ } catch (err) {
659
+ return {
660
+ isError: true,
661
+ content: [{
662
+ type: "text",
663
+ text: `Error: Failed to write configuration file: ${err.message}`
664
+ }]
665
+ };
666
+ }
667
+ }
668
+
669
+ if (name === "check_status") {
670
+ const license = checkLicenseStatus();
671
+ const tier = license.isPro ? 'PRO' : 'FREE';
672
+ const tierIcon = license.isPro ? '✅' : '🔓';
673
+ const profileList = license.isPro
674
+ ? 'All 23 profiles active (General, Dev, Medical, Legal, Finance, HR…)'
675
+ : 'General only — PRO unlocks 22 industry profiles';
676
+ const sizeLimit = license.isPro ? 'Unlimited' : '50,000 characters per request';
677
+
678
+ const configPath = resolveConfigPath();
679
+ let rulesStatus = '';
680
+ let configPathSnippet = 'Not found';
681
+
682
+ if (fs.existsSync(configPath)) {
683
+ const localPath = path.resolve(process.cwd(), 'privacyscrubber.json');
684
+ if (configPath === localPath) {
685
+ configPathSnippet = './privacyscrubber.json';
686
+ } else {
687
+ configPathSnippet = '~/privacyscrubber.json';
688
+ }
689
+
690
+ if (license.isPro) {
691
+ const rulesCount = loadCustomRules().length;
692
+ rulesStatus = `✅ Active (${rulesCount} rule${rulesCount !== 1 ? 's' : ''})`;
693
+ } else {
694
+ rulesStatus = '🔒 Ignored (requires PRO)';
695
+ }
696
+ } else {
697
+ rulesStatus = 'Not found';
698
+ }
699
+
700
+ const metricsSummary = getSessionMetricsSummary();
701
+
702
+ const lines = [
703
+ '╔══════════════════════════════════════════════════╗',
704
+ `║ PrivacyScrubber MCP Server v${MCP_VERSION.padEnd(10)} ║`,
705
+ '╠══════════════════════════════════════════════════╣',
706
+ `║ ${tierIcon} Tier: ${tier.padEnd(43)}║`,
707
+ `║ 📊 Session requests: ${String(sessionRequestCount).padEnd(27)}║`,
708
+ `║ 📁 Input size limit: ${sizeLimit.padEnd(27)}║`,
709
+ '╠══════════════════════════════════════════════════╣',
710
+ `║ 🏷️ Profiles: ${profileList.substring(0,35).padEnd(35)}║`,
711
+ `║ 📋 Custom rules: ${rulesStatus.padEnd(31)}║`,
712
+ `║ ⚙️ Config file: ${configPathSnippet.padEnd(31)}║`,
713
+ `║ 📈 Metrics: ${metricsSummary.substring(0,36).padEnd(36)}║`,
714
+ '╠══════════════════════════════════════════════════╣',
715
+ ];
716
+
717
+ if (license.isPro) {
718
+ lines.push(
719
+ '║ ✅ PRO is active. All features unlocked. ║',
720
+ '║ To regenerate your key or manage billing: ║',
721
+ '║ https://privacyscrubber.com/pricing ║'
722
+ );
723
+ } else {
724
+ lines.push(
725
+ '║ 💳 Upgrade to PRO — $110 Lifetime ║',
726
+ '║ https://privacyscrubber.com/pricing ║',
727
+ '╠══════════════════════════════════════════════════╣',
728
+ '║ After purchase, add your key to MCP config: ║',
729
+ '║ ║',
730
+ '║ Claude Desktop / Cursor / Windsurf: ║',
731
+ '║ "PRIVACYSCRUBBER_KEY": "<your-key-here>" ║',
732
+ '║ ║',
733
+ '║ Full setup guide: ║',
734
+ '║ https://privacyscrubber.com/features/mcp/ ║'
735
+ );
736
+ }
737
+
738
+ lines.push('╚══════════════════════════════════════════════════╝');
739
+
740
+ return {
741
+ content: [{ type: "text", text: lines.join('\n') }]
742
+ };
349
743
  }
350
744
 
351
745
  return {
352
746
  isError: true,
353
- content: [
354
- {
355
- type: "text",
356
- text: `Unknown tool: ${name}`
357
- }
358
- ]
747
+ content: [{ type: "text", text: `Unknown tool: ${name}` }]
359
748
  };
360
749
  } catch (error) {
361
750
  return {
362
751
  isError: true,
363
- content: [
364
- {
365
- type: "text",
366
- text: `Error executing tool: ${error.message}`
367
- }
368
- ]
752
+ content: [{ type: "text", text: `Error executing tool: ${error.message}` }]
369
753
  };
370
754
  }
371
755
  });
372
756
 
373
- // Run helper
757
+ function resolveConfigPath() {
758
+ let configPath = path.resolve(process.cwd(), 'privacyscrubber.json');
759
+ if (!fs.existsSync(configPath)) {
760
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
761
+ if (homeDir) {
762
+ configPath = path.resolve(homeDir, 'privacyscrubber.json');
763
+ }
764
+ }
765
+ return configPath;
766
+ }
767
+
768
+ function getSessionMetricsSummary() {
769
+ const counts = {};
770
+ Object.keys(sessionMap).forEach(token => {
771
+ const match = token.match(/^\[([A-Z_]+)_\d+\]$/);
772
+ if (match) {
773
+ const type = match[1];
774
+ counts[type] = (counts[type] || 0) + 1;
775
+ }
776
+ });
777
+
778
+ if (Object.keys(counts).length === 0) {
779
+ return '0 entities masked';
780
+ }
781
+
782
+ const parts = [];
783
+ if (counts.NAME) parts.push(`${counts.NAME} Name${counts.NAME > 1 ? 's' : ''}`);
784
+ if (counts.EMAIL) parts.push(`${counts.EMAIL} Email${counts.EMAIL > 1 ? 's' : ''}`);
785
+ if (counts.PHONE) parts.push(`${counts.PHONE} Phone${counts.PHONE > 1 ? 's' : ''}`);
786
+ if (counts.ID) parts.push(`${counts.ID} ID${counts.ID > 1 ? 's' : ''}`);
787
+ if (counts.CUSTOM) parts.push(`${counts.CUSTOM} Custom${counts.CUSTOM > 1 ? 's' : ''}`);
788
+
789
+ Object.entries(counts).forEach(([type, count]) => {
790
+ if (!['NAME', 'EMAIL', 'PHONE', 'ID', 'CUSTOM'].includes(type)) {
791
+ parts.push(`${count} ${type}${count > 1 ? 's' : ''}`);
792
+ }
793
+ });
794
+
795
+ return parts.join(', ');
796
+ }
797
+
798
+ function loadCustomRules() {
799
+ const license = checkLicenseStatus();
800
+ const configPath = resolveConfigPath();
801
+
802
+ if (fs.existsSync(configPath)) {
803
+ try {
804
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
805
+ const rules = config.customRules || [];
806
+ if (rules.length > 0) {
807
+ if (license.isPro) {
808
+ return rules.map(r => ({
809
+ pattern: r.pattern || r.regex || "",
810
+ label: (r.label || r.category || "CUSTOM").toUpperCase(),
811
+ regex: r.pattern || r.regex || "",
812
+ category: (r.label || r.category || "CUSTOM").toUpperCase()
813
+ })).filter(r => r.pattern);
814
+ } else {
815
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] Custom rules detected in privacyscrubber.json, but are ignored in the Free Tier.${colors.reset}\n${colors.cyan}👉 Set PRIVACYSCRUBBER_KEY to your PRO license key.${colors.reset}\n`);
816
+ }
817
+ }
818
+ } catch (e) {
819
+ process.stderr.write(`Error parsing privacyscrubber.json: ${e.message}\n`);
820
+ }
821
+ }
822
+ return [];
823
+ }
824
+
825
+ // Secrets scanner — uses the module-level DEVOPS_SECRETS_DETECTOR constant
826
+ function detectSecrets(text) {
827
+ const detected = [];
828
+ DEVOPS_SECRETS_DETECTOR.forEach(detector => {
829
+ // Reset lastIndex before every test — global regex retains state across calls
830
+ detector.regex.lastIndex = 0;
831
+ if (detector.regex.test(text)) {
832
+ detected.push(detector.name);
833
+ }
834
+ });
835
+ return detected;
836
+ }
837
+
374
838
  function performSanitization(text, profile) {
375
- const result = PrivacyScrubberCore.scrubText(text, [], {}, profile, sessionMap);
839
+ const customRules = loadCustomRules();
840
+ const result = PrivacyScrubberCore.scrubText(text, customRules, {}, profile, sessionMap);
376
841
 
842
+ const license = checkLicenseStatus();
843
+ if (!license.isPro && result.tokenMap) {
844
+ // Re-scan each masked token's original value against the shared detector.
845
+ // If it was a raw secret, restore it in the output and remove from sessionMap
846
+ // so it is never accidentally revealed by reveal_text.
847
+ Object.entries(result.tokenMap).forEach(([token, original]) => {
848
+ let isSecret = false;
849
+ for (const detector of DEVOPS_SECRETS_DETECTOR) {
850
+ detector.regex.lastIndex = 0;
851
+ if (detector.regex.test(original)) {
852
+ isSecret = true;
853
+ break;
854
+ }
855
+ }
856
+ if (isSecret) {
857
+ result.scrubbedText = result.scrubbedText.replace(token, original);
858
+ delete result.tokenMap[token];
859
+ }
860
+ });
861
+ }
862
+
377
863
  // Update our volatile map with new matches
378
864
  if (result.tokenMap) {
379
865
  Object.entries(result.tokenMap).forEach(([token, original]) => {
@@ -384,6 +870,15 @@ function performSanitization(text, profile) {
384
870
  return result.scrubbedText;
385
871
  }
386
872
 
873
+ function truncateIfFree(text, isPro) {
874
+ const MAX_FREE_CHARS = 50000;
875
+ if (!isPro && text.length > MAX_FREE_CHARS) {
876
+ process.stderr.write(`${colors.yellowBold}⚠️ [PrivacyScrubber] Input truncated to ${MAX_FREE_CHARS} characters (Free Tier Limit).${colors.reset}\n${colors.cyan}👉 Set PRIVACYSCRUBBER_KEY to your PRO license key for unlimited size.${colors.reset}\n`);
877
+ return { processedText: text.substring(0, MAX_FREE_CHARS), wasTruncated: true };
878
+ }
879
+ return { processedText: text, wasTruncated: false };
880
+ }
881
+
387
882
  // Start the server transport
388
883
  const transport = new StdioServerTransport();
389
884
  server.connect(transport).catch((error) => {