Priva-MCP
by adhidevara
README.md
# Priva-MCP โ Privacy & Compliance Layer
> ๐ฎ๐ฉ Versi Bahasa Indonesia: [README.id.md](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](#features)
- [Project structure](#project-structure)
- [Requirements](#requirements)
- [Install](#install)
- [Run locally](#run-locally)
- [Environment variables](#environment-variables)
- [Tools & mock data](#tools--mock-data)
- [Redaction reference](#redaction-reference)
- [Strict mode & numeric safety](#strict-mode--numeric-safety)
- [Zero-trust block & ELK incident logging](#zero-trust-block--elk-incident-logging)
- [Audit log format](#audit-log-format)
- [Quick manual smoke test](#quick-manual-smoke-test)
- [Connect to Claude Desktop](#connect-to-claude-desktop)
- [Testing](#testing)
- [How detection works](#how-detection-works)
- [Extending toward production](#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 generic `creditCard` / `bankAccount` / `nik`.
- **Numeric safety** โ `balance` / `amount` keep 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** โ `strict` mode, no `any`, no unchecked index access.
- **Unit tested** โ 28 tests via the built-in `node:test` runner.
---
## 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
```bash
npm install
```
---
## Run locally
### Development (auto-reload, runs the TS directly)
```bash
npm run dev
```
### Production (compile, then run JS)
```bash
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:
```jsonc
{
"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):
```jsonc
{
"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 bare `account`) โ `accountHolder` (a name) is never
masked as a bank number;
- there is no bare `id` token โ `customerId` is 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 `amount` values. 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:
1. **Blocks the entire response** โ it does not mask and forward.
2. 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.
```
3. Writes a single **ECS-style JSON incident** to **stderr** for ELK / Logstash.
The incident logs the offending **key / path / reason โ never the secret
value**:
```json
{"@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."}
```
4. Records the call in `audit.log` with `status_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](https://jsonlines.org/) โ one record per call.
Override the location with `AUDIT_LOG_PATH`.
Redacted (normal) call:
```json
{"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:
```json
{"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:**
```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:**
```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
1. Build so `dist/server.js` exists:
```bash
npm run build
```
2. Open Claude Desktop config:
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
- **macOS:** `~/Library/Application Support/Claude/claude_desktop_config.json`
3. Add Priva-MCP under `mcpServers` (use an **absolute path** to `dist/server.js`):
```json
{
"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 `command` at `npx` and `args` at
> `["tsx", "C:\\...\\priva-mcp\\src\\server.ts"]` to run the TypeScript
> directly (requires `tsx`, a dev dependency).
4. 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
```bash
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 like `customerId` / `accountHolder`), amount-key detection, forbidden
word-token detection.
- `redactor.ts` โ Layer 1 `KEY_MATCH`, Layer 2 `REGEX_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 a `nik`
field 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 become `X` and
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.
---
## Data flow โ what reaches Anthropic
The MCP server runs **locally** (Claude Desktop spawns it over stdio). But the
Claude *model* runs on Anthropic's servers, so whatever a tool returns to the
client is included in the conversation context sent to Anthropic. Priva-MCP's job
is to make sure only **masked** data ever crosses that boundary.
```
โโ your machine (local) โโโโโโโโโโโโโโโโโโโโโโโโ Anthropic (cloud) โโ
Internal API โโrawโโโถ Priva-MCP โโmaskedโโโถ Claude Desktop โโmasked onlyโโโถ Claude model
(DB/REST) (mask+block) (MCP client) โ
โ โ
โผ โ trust boundary
audit.log + ELK incident โโ stays local ๐ โ
```
- **Sent to Anthropic:** the masked tool output (e.g. `REDACTED-CIF`,
`XXXX-โฆ-1111`, `balance: 0`) plus the prompts you type โ the model needs them
to answer.
- **Never sent:** raw PII (exists only in the local process's RAM), blocked
forbidden fields, and the `audit.log` / stderr incident logs โ all stay on your
machine.
- **Keep in mind:**
- A partial mask (e.g. last 4 digits) still leaves the machine; the full
secret does not.
- Your own chat messages are **not** scrubbed โ Priva-MCP only filters *tool
output*, not your prompt. Don't paste raw PII directly into chat.
- With `COMPLIANCE_STRICT=false`, real balances/amounts are sent.
- Coverage equals rule quality โ fields not matched by `KEY_RULES` fall back to
the Layer 2 regex; what it misses could pass through.
- Data sent to Anthropic is then subject to Anthropic's own data policy for your
plan; Priva-MCP minimizes *what* is sent, not its downstream retention.
---
## Landing page (Vercel)
A static landing page lives in [`web/`](web/index.html) โ 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.json`](vercel.json) routes `/` to `web/index.html` and serves the rest
as static assets โ no framework or build command needed.
- **Root-directory mode:** alternatively, set Vercel's *Root Directory* to `web`
and leave the build command empty.
- **Local preview:** `npx serve web` (or open `web/index.html` directly).
---
## 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.
```
TDQS
A4/5.0
Scored across 2 tools
Disambiguation5/5
Both tools target distinct resources: one retrieves a customer profile, the other a financial report. There is no overlap, and each has a clear purpose.
Naming Consistency5/5
Both tools follow the same 'get_<noun>' pattern, making it predictable and easy for an agent to understand the action and resource.
Tool Count3/5
With only 2 tools, the server is at the lower boundary of acceptable scope. While focused, it may feel thin for a comprehensive customer data server.
Completeness2/5
The tool surface only covers read operations. Common operations like listing customers, updating profiles, or handling account statements are missing, leaving notable gaps.
Maintenance
ActivityInactive
ResponsivenessNo issues