@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/.well-known/mcp/server-card.json +1 -1
- package/README.md +247 -25
- package/bundle.mcpb +0 -0
- package/index.js +546 -51
- package/manifest.json +2 -2
- package/package.json +21 -8
- package/scrubber-core.cjs +1 -1
- package/server.json +2 -2
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:
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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) => {
|