Audit Logging, Compliance, and Data Privacy

Every tool call made through an MCP server is a potential compliance event. Which user authorized it? Which model called it? What arguments were passed? What was the result? What data was accessed? In regulated industries (finance, healthcare, legal), the inability to answer these questions is itself a compliance violation. This lesson covers structured audit logging for MCP servers, retention policies, GDPR/HIPAA-relevant data minimization, and how to build audit trails that satisfy both security teams and auditors.

MCP audit logging diagram showing tool calls flowing to structured logs with user session model and result metadata dark
Every MCP tool invocation is an audit event: who, what, when, result, and duration.

The Audit Event Schema

A structured audit event captures everything needed to reconstruct what happened without storing sensitive payload data:

/**
 * @typedef {Object} AuditEvent
 * @property {string} eventId - UUID for this specific event
 * @property {string} timestamp - ISO 8601 UTC timestamp
 * @property {string} eventType - 'tool_call', 'resource_read', 'connection', 'auth_failure'
 * @property {Object} actor - Who initiated the action
 * @property {string} actor.userId - Subject from JWT (hashed if needed for GDPR)
 * @property {string} actor.clientId - OAuth client_id
 * @property {string} actor.ipAddress - Originating IP
 * @property {Object} target - What was acted on
 * @property {string} target.toolName - MCP tool name
 * @property {string} target.serverId - MCP server identifier
 * @property {Object} outcome - What happened
 * @property {boolean} outcome.success
 * @property {number} outcome.durationMs
 * @property {string} [outcome.errorType] - Error class if failed
 * @property {Object} metadata - Additional context
 * @property {string[]} metadata.scopesUsed - OAuth scopes in effect
 * @property {string} metadata.sessionId - MCP session identifier
 */

Audit Middleware for MCP Servers

import crypto from 'node:crypto';

export function createAuditMiddleware(auditLog) {
  return function wrapTool(name, schema, handler) {
    return async (args, context) => {
      const eventId = crypto.randomUUID();
      const start = Date.now();

      // Log the attempt (before execution)
      await auditLog.write({
        eventId,
        timestamp: new Date().toISOString(),
        eventType: 'tool_call',
        actor: {
          userId: hashIfPII(context.auth?.sub),
          clientId: context.auth?.client_id ?? 'unknown',
          ipAddress: context.clientIp ?? 'unknown',
        },
        target: {
          toolName: name,
          serverId: process.env.SERVER_ID ?? 'mcp-server',
          // Don't log args - may contain PII. Log arg keys only.
          argKeys: Object.keys(args),
        },
        metadata: {
          scopesUsed: (context.auth?.scope ?? '').split(' ').filter(Boolean),
          sessionId: context.sessionId ?? 'unknown',
          phase: 'attempt',
        },
      });

      let success = false;
      let errorType = null;
      let result;

      try {
        result = await handler(args, context);
        success = !result?.isError;
        if (result?.isError) errorType = 'tool_error';
      } catch (err) {
        errorType = err.constructor.name;
        throw err;
      } finally {
        // Log the outcome
        await auditLog.write({
          eventId,
          timestamp: new Date().toISOString(),
          eventType: 'tool_call',
          actor: {
            userId: hashIfPII(context.auth?.sub),
            clientId: context.auth?.client_id ?? 'unknown',
          },
          target: { toolName: name, serverId: process.env.SERVER_ID ?? 'mcp-server' },
          outcome: {
            success,
            durationMs: Date.now() - start,
            errorType,
          },
          metadata: {
            phase: 'result',
          },
        });
      }

      return result;
    };
  };
}

// Hash PII identifiers for GDPR compliance (still traceable via audit, but not directly PII)
function hashIfPII(userId) {
  if (!userId) return 'anonymous';
  return crypto.createHash('sha256').update(userId + process.env.PII_SALT).digest('hex').slice(0, 16);
}
Audit log record structure diagram showing fields actor target outcome metadata with compliance labels dark
A well-structured audit record contains actor, target, outcome, and metadata – without storing raw argument values.

Audit Log Storage and Retention

// Write audit events to multiple destinations for reliability
class AuditLogger {
  #writers;

  constructor(writers) {
    this.#writers = writers;  // Array of write functions
  }

  async write(event) {
    const line = JSON.stringify(event) + '\n';
    await Promise.allSettled(this.#writers.map(w => w(line)));
  }
}

// File-based (append-only log)
import fs from 'node:fs';
const fileWriter = (line) => fs.promises.appendFile('/var/log/mcp-audit.jsonl', line);

// Cloud logging (GCP Cloud Logging, AWS CloudWatch)
const cloudWriter = async (line) => {
  await fetch(process.env.LOG_ENDPOINT, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-ndjson' },
    body: line,
  });
};

const auditLog = new AuditLogger([fileWriter, cloudWriter]);

Compliance Data Minimization

// GDPR Article 5: data minimization - only collect what is necessary
// HIPAA: minimum necessary standard

const TOOL_DATA_CLASSIFICATIONS = {
  search_products: 'low',       // No PII
  get_customer_order: 'high',   // Contains PII - log arg keys only, hash userId
  process_payment: 'critical',  // PCI-DSS - never log arguments at all
  send_email: 'high',           // Contains email addresses
};

function getAuditConfig(toolName) {
  const classification = TOOL_DATA_CLASSIFICATIONS[toolName] ?? 'medium';
  return {
    logArgs: classification === 'low',            // Only log args for non-PII tools
    logResult: classification !== 'critical',     // Never log critical tool results
    hashUserId: classification !== 'low',         // Hash user IDs for PII tools
    retentionDays: classification === 'critical' ? 2555 : 365,  // 7 years for PCI, 1 year otherwise
  };
}

Querying Audit Logs

// Use structured JSON logs (NDJSON) for easy querying with tools like jq
// Find all failed tool calls in the last hour:
// cat /var/log/mcp-audit.jsonl | \
//   jq -c 'select(.eventType == "tool_call" and .outcome.success == false)'

// Count tool calls by tool name today:
// cat /var/log/mcp-audit.jsonl | \
//   jq -r '.target.toolName' | sort | uniq -c | sort -rn

// Find all actions by a specific user:
// cat /var/log/mcp-audit.jsonl | \
//   jq -c 'select(.actor.userId == "a1b2c3d4e5f6")'

Compliance Checklist

  • GDPR Art. 5 – Data minimization: Audit logs do not store raw PII; user IDs are hashed
  • GDPR Art. 17 – Right to erasure: Audit records use hashed user IDs, so deletion of the hash salt makes all records unlinkable
  • HIPAA minimum necessary: Tool result content not logged for tools that return PHI
  • SOC 2 Type II – Availability: Logs written to at least two destinations; file + cloud
  • SOC 2 Type II – Integrity: Log lines are append-only; no update/delete operations
  • PCI-DSS Req. 10 – Audit trails: All payment tool calls logged with timestamp, actor, and outcome (no card data)

What to Build Next

  • Add createAuditMiddleware to your MCP server’s three most sensitive tools. Verify that the audit log file is being written with structured JSON events.
  • Run the jq query above to count tool calls by name over one day and identify any unexpected usage patterns.

nJoy 😉

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.