Priva-MCP
A man-in-the-middle MCP proxy that sits between Claude and an internal API, automatically masking sensitive banking data before returning to Claude.
README
Priva-MCP ā Privacy & Compliance Layer
š®š© Versi Bahasa Indonesia: README.id.md
A man-in-the-middle MCP proxy that sits between Claude (the client) and an an internal API (the resource). It intercepts the output of internal tools and automatically masks sensitive banking data ā CIF numbers, account numbers, debit-card PANs, balances, emails, phone numbers, national IDs ā before anything is returned to Claude. If a field that must never leave the bank (password, PIN, CVV, secret) is detected, the entire response is blocked. Every call is recorded in an append-only audit trail, and severe breaches raise a structured incident log for an enterprise SIEM (ELK / Logstash).
Claude (Client) ā Priva-MCP (this proxy) ā Internal API
ā
āāā compliance engine ā dual-layer redaction (key + regex)
āāā zero-trust block ā drops response on forbidden fields
āāā audit logger ā audit.log (JSON Lines)
āāā incident logger ā stderr ECS JSON (ELK / Logstash)
Design principle: zero-trust toward PII. stdout is reserved for the MCP JSON-RPC stream and never carries data leaks; all diagnostics and incidents go to stderr; sensitive values are masked at the field level, not by blind global regex.
Table of contents
- Features
- Project structure
- Requirements
- Install
- Run locally
- Environment variables
- Tools & mock data
- Redaction reference
- Strict mode & numeric safety
- Zero-trust block & ELK incident logging
- Audit log format
- Quick manual smoke test
- Connect to Claude Desktop
- Testing
- How detection works
- Extending toward production
Features
- Official MCP TypeScript SDK (
@modelcontextprotocol/sdk) over stdio. - Two mock tools that simulate pulling data from an internal API:
get_customer_profileā banking profile by CIF.get_financial_reportā account statement by CIF.
- Dual-layer redaction interceptor ā every tool output passes through the
engine before reaching Claude:
- Layer 1 (key-based, stricter): the field key name decides the masker (case-insensitive). Removes the false-positive class where a 16-digit NIK accidentally passes the Luhn check and is masked as a credit card.
- Layer 2 (fallback, deep text scan): raw strings / free text / unspecific keys fall back to the ordered global regex pipeline.
- Banking field-level masking for
cifNumber,accountNumber,pan,phoneNumber,email, plus genericcreditCard/bankAccount/nik. - Numeric safety ā
balance/amountkeep their JSON number type (never turned into"X"), so the AI can still reason about them. Under strict compliance they are zeroed. - Zero-trust block ā a forbidden field (
password,pin,cvv,secret, ā¦) causes the whole response to be blocked; only a safe error reaches Claude. - Audit logging to
audit.log(JSON Lines):timestamp,tool_called,user_id_mock,status_compliance(CLEAN/REDACTED/BLOCKED), plus a per-category breakdown that records the detection method (KEY_MATCH/REGEX_MATCH). - SIEM-ready incident logs ā severe breaches and pipeline errors emit a single ECS-style JSON line to stderr for ELK / Logstash.
- Strict TypeScript ā
strictmode, noany, no unchecked index access. - Unit tested ā 28 tests via the built-in
node:testrunner.
Project structure
priva-mcp/
āāā src/
ā āāā server.ts # MCP entry point: pipeline, strict mode, incident logs
ā āāā compliance/ # Dual-layer redaction engine
ā ā āāā types.ts # SensitiveCategory, JsonValue, CriticalViolation, ā¦
ā ā āāā masking.ts # pure maskers (card, NIK, CIF, email, phone, ā¦)
ā ā āāā patterns.ts # KEY_RULES (Layer 1) + regex rules (Layer 2) + forbidden keys
ā ā āāā redactor.ts # ComplianceEngine (recursive redactObject)
ā ā āāā index.ts
ā āāā gateway/ # Proxy logic + mock internal API data
ā ā āāā mockData.ts # banking records (CIF-keyed)
ā ā āāā gateway.ts # InternalGateway (returns RAW data)
ā ā āāā index.ts
ā āāā logs/ # Audit trail
ā āāā auditLogger.ts
ā āāā index.ts
āāā test/ # node:test suites
ā āāā masking.test.ts
ā āāā patterns.test.ts
ā āāā redactor.test.ts
āāā audit.log # generated at runtime (git-ignored)
āāā package.json
āāā tsconfig.json
āāā README.md
Requirements
- Node.js >= 18.18 (developed on Node 22)
- npm
Install
npm install
Run locally
Development (auto-reload, runs the TS directly)
npm run dev
Production (compile, then run JS)
npm run build
npm start
The server speaks MCP over stdio, so on startup it waits for a client on stdin/stdout. The only thing printed to your terminal (stderr) is a readiness line:
[priva-mcp] v1.0.0 ready on stdio. strict=true audit=C:\...\priva-mcp\audit.log
Type checking only (no emit):
npm run typecheck.
Environment variables
| Variable | Default | Effect |
|---|---|---|
COMPLIANCE_STRICT |
true |
When true, balance / amount numbers are zeroed. Set to false to pass amounts through untouched (e.g. when the AI must calculate on real figures). |
AUDIT_LOG_PATH |
<cwd>/audit.log |
Absolute or relative path for the audit trail. |
Banking default is strict (
COMPLIANCE_STRICT=true) ā zero-trust.
Tools & mock data
Both tools take a CIF number (cifNumber) and an optional requestedBy
(recorded in the audit trail as user_id_mock).
| Tool | Argument | Returns |
|---|---|---|
get_customer_profile |
cifNumber, requestedBy? |
banking profile |
get_financial_report |
cifNumber, requestedBy? |
account statement |
Valid mock CIF ids: CIF-7782001, CIF-7782002.
Raw (pre-redaction) profile shape returned by the gateway:
{
"cifNumber": "CIF-7782001",
"customerName": "Andi Wijaya",
"accountNumber": "0012345678901",
"pan": "4111111111111111",
"phoneNumber": "+6281234567890",
"email": "andi.wijaya@example.com",
"balance": 15750000,
"currency": "IDR",
"branch": "KCP Sudirman Jakarta"
}
What Claude actually receives (strict mode):
{
"cifNumber": "REDACTED-CIF",
"customerName": "Andi Wijaya",
"accountNumber": "XXXXXXXXX8901",
"pan": "XXXX-XXXX-XXXX-1111",
"phoneNumber": "+XXXXXXXXXX890",
"email": "a***a@e***.com",
"balance": 0,
"currency": "IDR",
"branch": "KCP Sudirman Jakarta"
}
Redaction reference
Layer 1 ā key-based (field-level)
Key names are normalized (lower-cased, non-alphanumerics stripped) then matched by substring. Rules are evaluated in order; the first match wins.
| Key contains | Category | Mask | Example |
|---|---|---|---|
email |
email |
partial | andi@example.com ā a***i@e***.com |
nik, id_card, identity, ktp |
national_id |
keep last 4 | 3173012501900002 ā XXXXXXXXXXXX0002 |
cif |
cif |
full | CIF-7782001 ā REDACTED-CIF |
pan, card, credit |
credit_card |
Luhn ā grouped | 4111111111111111 ā XXXX-XXXX-XXXX-1111 |
phone, telp, mobile, msisdn |
phone |
keep last 3 | +6281234567890 ā +XXXXXXXXXX890 |
bankaccount, rekening, norekening, iban, accountnumber, virtualaccount |
bank_account |
keep last 4 | 0012345678901 ā XXXXXXXXX8901 |
balance, amount |
financial_amount |
numeric (see below) | 15750000 ā 0 (strict) |
Ordering matters: national_id is checked before credit_card, so a key
like id_card (which contains the card token) is classified as a national ID,
not a card. cif is checked before account/card so cifNumber is never
mis-handled.
Deliberately specific tokens avoid collisions:
- token is
bankaccount(not bareaccount) āaccountHolder(a name) is never masked as a bank number; - there is no bare
idtoken ācustomerIdis never treated as a NIK.
Layer 2 ā global regex fallback
Applied to raw strings, free text, and fields whose key is not specific. Rules
run in order; once a value is masked its digits become X and cannot be
re-matched by a later, broader rule.
| Order | Category | Notes |
|---|---|---|
| 1 | email |
unambiguous (@) |
| 2 | phone |
Indonesian mobile format |
| 3 | credit_card |
13ā19 digits, Luhn-validated |
| 4 | national_id |
exactly 16 contiguous digits |
| 5 | bank_account |
10ā15 contiguous digits |
Strict mode & numeric safety
balance / amount fields hold numbers the AI may need to compute on, so they
are never turned into an "X" string (that would corrupt the JSON number
type). Behavior depends on COMPLIANCE_STRICT:
| Mode | balance: 15750000 becomes |
Use case |
|---|---|---|
| strict (default) | 0 (still a number) |
Zero-trust: the figure is protected but the JSON stays valid/parseable. |
non-strict (COMPLIANCE_STRICT=false) |
15750000 (unchanged) |
The AI must calculate on real figures. |
Strict mode also zeroes statement
amountvalues. That is the intended policy; flip the env var per deployment if real numbers are required.
Zero-trust block & ELK incident logging
Some keys must never cross the proxy. They are detected by whole-word token
matching (camelCase and separators are split), so pin flags pinCode / mPin
but not innocent keys like shippingAddress.
Forbidden word-tokens: password, passwd, pwd, passphrase, pin, mpin,
otp, cvv, cvc, secret, privatekey, credential, credentials.
When a forbidden field is present, the server:
- Blocks the entire response ā it does not mask and forward.
- Returns a generic, information-free error to Claude (no PII, no field value):
Error: response blocked by privacy & compliance policy (a forbidden sensitive field was detected). The incident has been logged. - Writes a single ECS-style JSON incident to stderr for ELK / Logstash.
The incident logs the offending key / path / reason ā never the secret
value:
{"@timestamp":"2026-06-29T19:41:05.782Z","log.level":"error","log.logger":"priva-mcp.compliance","event.kind":"alert","event.category":"intrusion_detection","event.action":"compliance.critical_violation","event.outcome":"blocked","tool":"get_customer_profile","user_id_mock":"attacker-probe","violation_count":1,"violations":[{"path":"$.pin","key":"pin","reason":"forbidden sensitive field present in gateway response"}],"message":"Forbidden sensitive field detected in gateway response; response blocked before reaching the client."} - Records the call in
audit.logwithstatus_compliance: "BLOCKED".
Unexpected pipeline errors are contained the same way: a generic error to the
client, full detail (incl. error.stack_trace) to stderr under
event.action: "compliance.pipeline_error". stdout never carries a leak.
Ingest tip: point Filebeat / Logstash at the process stderr stream and parse each line as JSON. The fields follow Elastic Common Schema naming (
@timestamp,log.level,event.*,error.*).
Audit log format
audit.log is JSON Lines ā one record per call.
Override the location with AUDIT_LOG_PATH.
Redacted (normal) call:
{"timestamp":"2026-06-29T19:40:02.026Z","tool_called":"get_customer_profile","user_id_mock":"teller-01","status_compliance":"REDACTED","redactions":[{"category":"cif","method":"KEY_MATCH","count":1},{"category":"bank_account","method":"KEY_MATCH","count":1},{"category":"credit_card","method":"KEY_MATCH","count":1},{"category":"phone","method":"KEY_MATCH","count":1},{"category":"email","method":"KEY_MATCH","count":1},{"category":"financial_amount","method":"KEY_MATCH","count":1}],"total_redactions":6}
Blocked call:
{"timestamp":"2026-06-29T19:41:05.784Z","tool_called":"get_customer_profile","user_id_mock":"attacker-probe","status_compliance":"BLOCKED","redactions":[...],"total_redactions":6,"note":"critical violation: pin"}
| Field | Meaning |
|---|---|
timestamp |
ISO-8601 |
tool_called |
tool name |
user_id_mock |
requester (requestedBy, or mock-user-001) |
status_compliance |
CLEAN | REDACTED | BLOCKED |
redactions[] |
{ category, method, count } per category |
total_redactions |
sum across categories |
note |
optional (errors, blocks) |
Quick manual smoke test
Drive the server by hand with a JSON-RPC sequence piped to stdin.
PowerShell:
$lines = @(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0.0"}}}'
'{"jsonrpc":"2.0","method":"notifications/initialized"}'
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_customer_profile","arguments":{"cifNumber":"CIF-7782001","requestedBy":"teller-01"}}}'
)
$lines -join "`n" | node dist/server.js
bash:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0.0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_customer_profile","arguments":{"cifNumber":"CIF-7782001","requestedBy":"teller-01"}}}' \
| node dist/server.js
You should see the profile returned with sensitive fields masked, balance: 0
(strict), and a new REDACTED line in audit.log. To see amounts preserved,
prefix with COMPLIANCE_STRICT=false.
Connect to Claude Desktop
-
Build so
dist/server.jsexists:npm run build -
Open Claude Desktop config:
- Windows:
%APPDATA%\Claude\claude_desktop_config.json - macOS:
~/Library/Application Support/Claude/claude_desktop_config.json
- Windows:
-
Add Priva-MCP under
mcpServers(use an absolute path todist/server.js):{ "mcpServers": { "priva-mcp": { "command": "node", "args": ["C:\\Users\\adhi0\\Projects\\Development\\priva-mcp\\dist\\server.js"], "env": { "COMPLIANCE_STRICT": "true", "AUDIT_LOG_PATH": "C:\\Users\\adhi0\\Projects\\Development\\priva-mcp\\audit.log" } } } }On macOS/Linux use a normal path, e.g.
"/Users/you/priva-mcp/dist/server.js".Prefer not to build? Point
commandatnpxandargsat["tsx", "C:\\...\\priva-mcp\\src\\server.ts"]to run the TypeScript directly (requirestsx, a dev dependency). -
Fully restart Claude Desktop. The two tools appear in the tools menu. Ask Claude e.g. "get the profile for CIF-7782001" and confirm the returned data is masked.
Testing
npm test
Uses the built-in node:test runner via tsx (no extra dependencies). Coverage:
masking.tsā card / keep-last-4 / NIK / CIF / email / phone, plus edge cases.patterns.tsā Luhn, key normalization, key classification (incl. near-miss guards likecustomerId/accountHolder), amount-key detection, forbidden word-token detection.redactor.tsā Layer 1KEY_MATCH, Layer 2REGEX_MATCH, NIK false-positive fix, banking fields (cif/pan/accountNumber), strict vs non-strict numeric handling, and the zero-trust block (with violation path).
How detection works
Rules in src/compliance/patterns.ts drive both layers:
- Layer 1 (
KEY_RULES) maps key-name tokens to maskers and runs first for any field inside a structured object. This is the precise, high-confidence path ā it bypasses the blind regex entirely, so a 16-digit NIK in anikfield can never be mistaken for a credit card. - Layer 2 (
REDACTION_RULES) is the ordered global regex fallback for raw text and unspecific keys. Once a value is masked its digits becomeXand cannot be re-matched by a later, broader rule, which lets high-confidence rules (email, phone, Luhn-validated cards) run before generic numeric fallbacks.
The recursive ComplianceEngine.redactObject(input: unknown) walks objects and
arrays, narrows types at runtime (no unchecked any), tracks a JSON path for
incident reporting, and collects both the masked output and any critical
violations.
To add a category: append a KeyRule to KEY_RULES and/or a RedactionRule to
REDACTION_RULES. To swap the mock gateway for a real internal API: replace the
methods in src/gateway/gateway.ts ā the compliance, audit, and incident layers
stay untouched.
Landing page (Vercel)
A static landing page lives in web/ ā an intro to the project
plus a copy-paste implementation guide. It is plain HTML/CSS/JS (no build step).
Deploy options:
- Zero-config import: import the repo into Vercel. The root
vercel.jsonroutes/toweb/index.htmland serves the rest as static assets ā no framework or build command needed. - Root-directory mode: alternatively, set Vercel's Root Directory to
weband leave the build command empty. - Local preview:
npx serve web(or openweb/index.htmldirectly).
Extending toward production
- Role-based masking ā vary mask depth by the caller's clearance (e.g. a fraud analyst may see more than a chatbot).
- NER for free text ā replace regex heuristics with a dedicated PII model for unstructured fields.
- SIEM transport ā ship audit + incident records over a real transport (Kafka / HTTP) instead of files / stderr; the ECS field names already align with Elastic.
- Config-driven policy ā externalize
KEY_RULES, forbidden words, and strict mode into a signed policy file with hot reload. - Tokenization / FPE ā replace masking with format-preserving encryption so values remain join-able downstream without exposing the plaintext.
Recommended Servers
playwright-mcp
A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.
Magic Component Platform (MCP)
An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.
Audiense Insights MCP Server
Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.
VeyraX MCP
Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.
graphlit-mcp-server
The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.
Kagi MCP Server
An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.
E2B
Using MCP to run code via e2b.
Neon Database
MCP server for interacting with Neon Management API and databases
Exa Search
A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.
Qdrant Server
This repository is an example of how to create a MCP server for Qdrant, a vector search engine.