PII redaction + audit
This is the origin use case for mcpose: a financial-grade MCP server where every Elasticsearch tool response must be scrubbed of PII before it reaches the LLM or the audit log. Two middleware layers sit in front of the upstream: one redacts the response, the other records it in a tamper-evident audit trail.
The recipe
The compact form wires a PII redaction middleware ahead of the audit middleware from @mcpose/audit.
The PII patterns come from the runnable example, covering social security style IDs, passport-style codes, card numbers, and email addresses.
The excerpt assumes a connected backend, an authenticating extractJwt resolver, and your auditLog and manifestStore persistence adapters.
See the complete audit setup.
import { hasToolContent, mapToolResult, startHttpProxy } from 'mcpose';
import type { ToolMiddleware } from 'mcpose';
import { createAuditMiddleware, createDefaultSigningKeyProvider, createSensitivityResolver } from '@mcpose/audit';
// 1 · PII redaction middleware
const PII_PATTERNS: RegExp[] = [
/\b\d{9}\b/g, // 9-digit IDs (social security style)
/[A-Z]{2}\d{6}/g, // alphanumeric codes (passport style)
/\b\d{16}\b/g, // 16-digit card numbers
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, // email addresses
];
function createPiiMiddleware(patterns: RegExp[]): ToolMiddleware {
return async (req, next) => {
const result = await next(req);
if (!hasToolContent(result)) return result;
return mapToolResult(result, {
onText: item => ({ ...item, text: patterns.reduce((t, re) => t.replace(re, '[REDACTED]'), item.text) }),
onOther: () => null,
onStructured: () => undefined,
});
};
}
// 2 · Audit middleware: tamper-evident events + signed replay manifest
const auditHandle = createAuditMiddleware({
signingKey: createDefaultSigningKeyProvider(process.env.AUDIT_SECRET!),
sensitivityResolver: createSensitivityResolver({ search: 'medium', transfer: 'high' }),
onEvent: (e) => auditLog.append(e),
onManifest: (m) => manifestStore.save(m),
});
// 3 · Wire it together: PII first, audit second
await startHttpProxy(backend, {
name: 'pii-gateway',
toolMiddleware: [
createPiiMiddleware(PII_PATTERNS), // PII first
auditHandle.middleware, // audit sees clean data
],
}, {
resolveIdentity: extractJwt,
onSessionClosed: (id) => auditHandle.closeSession(id),
});Matching text is redacted before audit sees the response; non-text blocks and structured content are dropped in this example.
Legacy toolResult responses pass through unchanged and need a separate policy if your upstream returns them.
Why the order matters
Middleware arrays use response-processing order: the first element processes the response first.
With toolMiddleware: [piiMW, auditMW], the PII middleware redacts the response, and the audit layer processes it last, so it sees the transformed response.
Swap the order and raw PII lands in the audit log.
This is why ordering is part of the compliance story, not just a style choice. The Middleware model explains the full onion semantics.
Sensitivity tiers
createSensitivityResolver maps tool names to sensitivity tiers that decide how an audit event stores its payloads.
searchresolves to'medium': the event keeps plaintextinputRawandoutputRaw, andassertPiiRedactedrequires that no PII pattern matches them.transferresolves to'high': the event is structurally encrypted, withinputEncryptedandoutputEncryptedin place of the plaintext payloads.- Any tool missing from the map resolves to
'high'too, so a new or unmapped tool fails closed to encryption instead of leaking plaintext.
The resolver fails closed even on bad map values: anything that is not a known tier resolves to 'high'.
The runnable example adds get_balance: 'low' for read-only lookups.
Identity and session close
resolveIdentity runs once when an HTTP session is established, and the resolved Identity is stamped on every ProxyContext in that session.
Errors from resolveIdentity abort the session with HTTP 401.
onSessionClosed wires session teardown into the audit layer: auditHandle.closeSession(id) finalizes the session's event chain and flushes its signed replay manifest.
Miss the hook and the manifest is never closed.
See Identity and sessions for the full model.
Response ordering alone does not redact audit inputs: audit captures the incoming arguments before inner middleware runs.
Sanitize arguments before that capture or classify and encrypt them according to your data policy.
For results, use mapToolResult to decide what happens to text, non-text blocks, and structured content; a text-only replacement can leave other channels unchanged.
Proving the redaction
@mcpose/testing ships compliance assertions for exactly this guarantee.
assertPiiRedacted(event, patterns) throws if any pattern still matches a plaintext field, or if a high-tier event is not structurally encrypted.
import { assertPiiRedacted } from '@mcpose/testing';
// Throws if PII survived redaction
assertPiiRedacted(event, [/\b\d{16}\b/g, /[A-Z]{2}\d{6}/g]);Run it over every recorded event, and a regression in the redaction patterns fails the build instead of shipping PII to the log. The assertions are deliberately keyless: the signing secret is never available to tests.
Reference implementation
elastic-pii-proxy is a production example of this pattern: an Elasticsearch MCP proxy that uses mcpose with PII redaction and @mcpose/audit to serve financial data safely to LLM agents.
Run the example
The full runnable example is examples/pii-redaction-audit.ts in the mcpose repository, started with npx tsx pii-redaction-audit.ts.
The example proxies a real upstream MCP server.
Set UPSTREAM_URL to an HTTP/SSE endpoint (the default is http://localhost:9000/mcp) or switch createBackendClient to a stdio command, and set AUDIT_SECRET to a real secret.
createDefaultSigningKeyProvider is fine for development and single-trust deployments; in production, use a KMS-backed SigningKeyProvider.