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.

pii-redaction-audit.ts · excerpt
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

Note · Middleware order

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.

  • search resolves to 'medium': the event keeps plaintext inputRaw and outputRaw, and assertPiiRedacted requires that no PII pattern matches them.
  • transfer resolves to 'high': the event is structurally encrypted, with inputEncrypted and outputEncrypted in 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.

audit.test.ts · excerpt
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

Note · 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.

Note · Upstream required

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.

Next steps