Lesson 36 of 55: Audit Logging and Compliance for MCP Tool Calls

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
 */

This schema matters because unstructured log messages (“user called tool X”) become useless the moment you need to answer a compliance question like “which client accessed customer data in the last 30 days?” Structured events with consistent fields let you query, aggregate, and alert on audit data using standard tooling.

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);
}

A common mistake is logging tool arguments directly, which can expose PII, credentials, or sensitive query parameters in your audit trail. The middleware above deliberately logs only argument keys, not values. This gives you enough information to reconstruct what happened without turning your audit log into a data breach liability.

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]);

Writing to multiple destinations with Promise.allSettled is deliberate: if cloud logging is temporarily unavailable, the local file still captures the event. Audit logs must survive transient infrastructure failures, because a gap in your audit trail during an incident is exactly when you need the data most.

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
  };
}

In regulated environments, over-logging is almost as dangerous as under-logging. If your audit trail contains raw customer emails or health records, the audit system itself becomes subject to the same data protection rules as the primary database. Classify each tool’s data sensitivity upfront to avoid creating a compliance problem while trying to solve one.

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")'

NDJSON (newline-delimited JSON) is the format of choice here because each line is an independent JSON object. This means you can append logs atomically, stream them to cloud logging services, and query them with jq without loading the entire file into memory. It also makes log rotation straightforward: just archive and compress old files.

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.