Blog · Compliance
MCP server audit trail design for SOC 2 and GDPR compliance
An MCP server that touches customer data, accesses databases with PII, or calls third-party APIs on behalf of authenticated users is almost certainly in scope for your organization's SOC 2 audit and possibly GDPR record-keeping obligations. Most MCP servers ship with no audit trail at all — which is a finding in both frameworks. This post is the implementation guide: what fields you must capture, how to make records tamper-evident, what retention rules apply, and what evidence looks like when an auditor or a data subject asks for it.
2026-06-05 · 8 min read
Why MCP servers are a compliance blind spot
Traditional web application audit trails are well understood: every HTTP request carries an authenticated user identity, every database write can be tagged with a user ID, and the application log is a natural audit trail you can query. MCP servers break this model in three ways:
- The actor is an LLM, not a human. The tool call to
read_database_tablewas initiated by the orchestrator based on a prompt — the human user is one hop removed. Your audit log needs to capture both the human session identity and the LLM-generated tool call chain. - Operations happen at machine speed. A single user prompt can generate dozens of tool calls in seconds. An audit trail that only records "user logged in" and "user logged out" is useless. You need per-tool-call granularity.
- Ambient credentials mean the scope of access is larger than any individual call. If a database MCP server holds a read/write credential for a production PostgreSQL instance, the audit trail must reflect that every call was made under that credential set — not just the calls that modified data.
SOC 2 Trust Service Criteria CC7.2 (monitoring), CC7.4 (incident response), and CC6.1 (logical access controls) all require evidence of who accessed what and when. GDPR Article 30 requires Records of Processing Activities that include the categories of data accessed and the legal basis. Neither framework exempts automated tool calls.
The minimum viable audit log entry
Every tool call invocation must generate one structured log entry. The fields below are the minimum — missing any of the Required ones will fail a SOC 2 audit review and may fail an Article 30 review for GDPR-scoped deployments.
| Field | Type | Required? | Compliance driver |
|---|---|---|---|
event_id |
UUID v4 | Required | Uniquely identifies the record for evidence requests; also used for idempotency on delivery retries |
timestamp |
ISO 8601 UTC | Required | SOC 2 CC7.2 — timeline reconstruction; GDPR breach notification Article 33 (must notify within 72 hours of awareness) |
session_id |
string | Required | Groups all tool calls in one LLM session for lateral movement analysis |
user_id |
string | Required | SOC 2 CC6.1 — logical access; GDPR Article 30 — data subject identification for Subject Access Requests |
tool_name |
string | Required | Names the operation; enables per-tool anomaly detection |
input_summary |
string | Required | Sanitized digest of tool arguments — see sanitization rules below |
outcome |
enum: success / error / rejected | Required | SOC 2 CC7.4 — incident response evidence; authorization rejections are the first signal of probing |
data_classes |
string[] | Required (GDPR-scoped) | GDPR Article 30 — categories of personal data processed; must be pre-defined taxonomy, not free text |
credential_ref |
string | Required | Identifies which credential set was active (e.g., vault:db/prod/creds#lease-abc123) — NOT the credential value itself |
response_bytes |
integer | Recommended | Enables data exfiltration anomaly detection — sessions extracting anomalous volumes stand out in aggregation queries |
latency_ms |
integer | Recommended | Performance baseline + anomaly detection for unusual upstream interaction patterns |
client_ip |
string | Recommended | Geo-anomaly detection; note GDPR itself treats IP addresses as personal data requiring Article 30 entry |
prev_hash |
string (SHA-256 hex) | Recommended | Tamper-evidence chain — see implementation below |
// Audit log entry structure (TypeScript)
interface AuditEntry {
event_id: string; // crypto.randomUUID()
timestamp: string; // new Date().toISOString()
session_id: string; // from session auth context
user_id: string; // from session auth context — never from tool input
tool_name: string; // e.g. "query_customer_records"
input_summary: string; // sanitized — see below
outcome: 'success' | 'error' | 'rejected';
data_classes: DataClass[]; // e.g. ['PII.email', 'PII.address']
credential_ref: string; // vault path or secret ARN — not the value
response_bytes: number;
latency_ms: number;
client_ip: string;
prev_hash: string; // SHA-256 of previous entry JSON
}
type DataClass =
| 'PII.name' | 'PII.email' | 'PII.address' | 'PII.phone'
| 'financial.card' | 'financial.bank' | 'financial.transaction'
| 'health.record' | 'credentials.token' | 'none';
Input summary sanitization rules
The input_summary field is the most dangerous field to get wrong. Log too much and you log credential values, PII, and payment card data — which is a compliance violation in itself. Log too little and the audit trail is useless for incident reconstruction.
Sanitization rules for input_summary
Always strip: any string matching a credential pattern (bearer token, API key, private key PEM block, database password). Use a regex allowlist approach — only pass through fields explicitly declared safe, default to redacting everything else.
Always truncate: any string argument to 200 characters maximum with a […truncated] suffix. Raw query text could contain WHERE clause values including SSNs and card numbers.
Always hash, never log: email addresses, phone numbers, national ID numbers. Log the SHA-256 hash with a pii: prefix — this lets you correlate a known subject's activity without storing the value in plaintext in your audit log.
Log the parameter name, not the value: for parameters flagged sensitive: true in the tool schema, log { "password": "[REDACTED]" } — the key tells you the field was supplied, the value is gone.
function sanitizeInputSummary(
toolName: string,
rawArgs: Record<string, unknown>,
schema: ToolSchema
): string {
const safe: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rawArgs)) {
const fieldSchema = schema.properties?.[key];
const sensitive = fieldSchema?.['x-sensitive'] === true;
const isPii = fieldSchema?.['x-pii'] === true;
if (sensitive) {
safe[key] = '[REDACTED]';
} else if (isPii && typeof value === 'string') {
// Log a deterministic hash — correlatable but not reversible
safe[key] = 'pii:' + createHash('sha256').update(value).digest('hex').slice(0, 16);
} else if (typeof value === 'string' && value.length > 200) {
safe[key] = value.slice(0, 200) + '[…truncated]';
} else {
safe[key] = value;
}
}
return JSON.stringify(safe);
}
Tamper-evident log chaining
A flat append-only log is not tamper-evident — an attacker with write access to the log store can delete or modify entries without leaving evidence. A hash chain links each entry cryptographically to the previous one: modifying any entry invalidates the chain from that point forward.
This is the same construction used in certificate transparency logs and blockchain ledgers. For MCP audit trails it provides a property SOC 2 auditors call "log integrity" — they can verify that the log you present as evidence has not been altered since it was written.
import { createHash } from 'crypto';
class AuditLogger {
private lastHash = '0'.repeat(64); // genesis hash
async write(entry: Omit<AuditEntry, 'event_id' | 'prev_hash'>): Promise<void> {
const record: AuditEntry = {
...entry,
event_id: crypto.randomUUID(),
prev_hash: this.lastHash,
};
// Serialize deterministically — key order must be stable
const serialized = JSON.stringify(record, Object.keys(record).sort());
const hash = createHash('sha256').update(serialized).digest('hex');
// Append to write-once log store (S3 Object Lock, CloudWatch Logs,
// or a table with a DB-level append-only constraint)
await this.appendToStore(record);
// Update in-memory hash — used as prev_hash for the next entry
this.lastHash = hash;
}
async verify(entries: AuditEntry[]): Promise<boolean> {
let prev = '0'.repeat(64);
for (const entry of entries) {
if (entry.prev_hash !== prev) return false;
const { event_id: _, prev_hash: __, ...rest } = entry;
const serialized = JSON.stringify(
{ event_id: entry.event_id, prev_hash: entry.prev_hash, ...rest },
Object.keys(entry).sort()
);
prev = createHash('sha256').update(serialized).digest('hex');
}
return true;
}
}
For distributed MCP server deployments (multiple instances), each instance maintains its own hash chain. Auditors verify each chain independently, then cross-reference session IDs across chains. A single logical session that spans two server instances will appear as entries in both chains — the session_id binds them together.
SOC 2 requirements mapped to audit trail fields
CC6.1 — Logical access controls
Auditors look for evidence that access to systems and data is restricted to authorized entities. Your MCP audit trail provides this evidence if every entry includes user_id, session_id, and credential_ref. They will ask: "Show me all access to the customer database table in the last 90 days." The query is SELECT * FROM audit_log WHERE tool_name = 'query_customer_records' ORDER BY timestamp.
The evidence fails if user_id is absent (you cannot prove a human authorized the access), if credential_ref is absent (you cannot prove access was made under a controlled credential), or if the log can be modified (you cannot prove the record is complete).
CC7.2 — Monitoring for security events
CC7.2 requires continuous monitoring. For MCP servers this means the audit log must be queryable in near-real-time and you must have alert rules defined. The minimum alerting set:
- Any single session where
SUM(response_bytes) > 10 MB— potential bulk exfiltration - Any session with more than 500 tool calls — potential agent loop or automated scraping
- Any
outcome = 'rejected'event followed by more than 3 subsequent tool calls in the same session — potential authorization probing - Any tool call where
latency_ms > p99 * 3— potential injection of large payloads or upstream manipulation - Hash chain verification failure — log integrity event, immediate incident response required
CC7.4 — Incident response
CC7.4 requires a documented incident response procedure. For MCP servers, the audit trail is the primary evidence source during an incident. The log must remain available and unmodified during and after the incident — this is why write-once storage (S3 Object Lock in Compliance mode, CloudWatch Logs with a retention policy, or an append-only database table) is required. Do not store your audit log in a database that also runs the MCP server — a compromised server could modify it.
GDPR requirements mapped to audit trail fields
Article 30 — Records of Processing Activities (RoPA)
Article 30 requires controllers to maintain records of processing activities. An MCP server that queries, modifies, or transmits personal data is a processing activity. Your RoPA entry for an MCP server must include:
- The name and contact details of the controller and (where applicable) joint controller
- The purposes of the processing — "customer support automation via LLM agent" is a valid description
- The categories of data subjects — e.g., "customers, employees"
- The categories of personal data — this is where your
data_classestaxonomy feeds directly into the RoPA - The categories of recipients — including third-party APIs the MCP server calls (e.g., Stripe, SendGrid, Salesforce)
- Transfers to third countries and safeguards in place
- Retention time limits
The data_classes field in your audit log must use a pre-defined taxonomy that matches your RoPA categories exactly. Free-text data class descriptions make RoPA updates impossible to automate.
Article 15 / 20 — Subject Access Requests and Data Portability
When a data subject submits a SAR, you must provide all personal data you hold about them. If your MCP server's audit log contains hashed PII (the pii: prefix pattern above), you can query by computing the hash of the data subject's known identifiers (email, phone) and returning matching entries.
A SAR response for an MCP server should include: the list of tool calls made in sessions attributed to that data subject, the data classes accessed in each session, and the upstream services those tool calls interacted with. You do not include the raw response content (that likely contains other users' data) but you do include response_bytes and data_classes per call as evidence of the scope of processing.
Article 17 — Right to erasure
Right to erasure creates a tension with audit trail integrity. You cannot delete a log entry that is part of a hash chain without invalidating the chain. The correct resolution is:
- Store the
input_summaryand any PII-containing fields in a separate linked table that can be deleted - The main chain entry stores only the
event_id, hash fields, timestamps, tool name, outcome, and a reference to the deleted data row - On erasure, delete the linked row and mark the main entry with
data_erased: true— the chain integrity is preserved, the personal data is gone
This approach satisfies both the hash chain integrity requirement (the chain still verifies) and the erasure requirement (the personal data is gone). Document this two-table architecture in your RoPA and privacy notice.
Retention periods
Retention policy is where SOC 2 and GDPR pull in opposite directions. SOC 2 audits typically cover 12 months and auditors want to see 12 months of log history. GDPR's storage limitation principle (Article 5(1)(e)) says personal data must not be kept longer than necessary.
The practical resolution is a tiered retention scheme:
| Tier | Contents | Retention | Storage |
|---|---|---|---|
| Hot audit log | All fields including input_summary and data_classes | 90 days | Queryable database (read-append only) |
| Warm archive | event_id, timestamp, session_id, tool_name, outcome, response_bytes, hash chain fields only — PII stripped | 12 months | S3 Object Lock (Governance mode) or equivalent |
| Incident archive | Full entries for sessions flagged as security incidents | 7 years (or jurisdiction requirement) | S3 Object Lock (Compliance mode) |
The 90-day hot tier satisfies GDPR's storage limitation for active-incident investigation data. The 12-month warm archive satisfies SOC 2's evidence window with PII already stripped. Incident archives are covered under "legal obligation" as the lawful basis for extended retention — document this in your privacy notice.
Responding to an auditor evidence request
When a SOC 2 auditor asks for access control evidence covering the previous 12 months, the following query pattern covers CC6.1:
-- Who accessed what, when, under what credential
SELECT
DATE_TRUNC('day', timestamp) AS day,
user_id,
tool_name,
credential_ref,
COUNT(*) AS call_count,
SUM(response_bytes) AS total_bytes,
COUNT(*) FILTER (WHERE outcome = 'rejected') AS rejections
FROM audit_log_warm
WHERE timestamp >= NOW() - INTERVAL '12 months'
GROUP BY 1, 2, 3, 4
ORDER BY 1 DESC, 5 DESC;
-- Hash chain integrity verification (run before exporting to auditor)
SELECT
COUNT(*) FILTER (WHERE prev_hash IS NULL) AS genesis_count,
COUNT(*) FILTER (WHERE prev_hash IS NOT NULL) AS chained_count,
MIN(timestamp) AS oldest,
MAX(timestamp) AS newest
FROM audit_log_warm;
Export the result set as CSV plus the output of the verify() method from the hash chain implementation above, signed with your organization's GPG key. The signature proves you are presenting the unmodified log at the time of the export.
What SkillAudit checks for
When SkillAudit scans an MCP server for compliance readiness, the Maintenance and Credentials axes include audit trail checks:
| Finding | Axis | Severity | Compliance risk |
|---|---|---|---|
| No audit log emitted for tool calls | Maintenance | HIGH | SOC 2 CC6.1 / CC7.2 non-conformity; GDPR Article 30 RoPA gap |
| user_id absent or taken from tool input | Security | HIGH | Audit trail is attributable only to the LLM, not the human — CC6.1 failure |
| Credential values present in log entries | Credentials | HIGH | Credential exposure in log store; audit log itself becomes a breach vector |
| PII logged in plaintext (email, SSN, card number in input_summary) | Credentials | HIGH | GDPR breach: audit log itself is unlawfully retaining personal data beyond purpose |
| Log store is writable — no append-only constraint | Maintenance | MEDIUM | Log integrity not provable; SOC 2 CC7.4 evidence may be rejected |
| No data_classes field or free-text values | Maintenance | MEDIUM | GDPR Article 30 RoPA cannot be populated automatically; manual gap |
| No retention policy configured | Maintenance | MEDIUM | GDPR storage limitation principle violation; SOC 2 evidence may span unintended periods |
| No hash chain — log entries not linked | Maintenance | LOW | Log tampering not detectable; reduces evidentiary weight in SOC 2 review |
Implementation checklist
Use this as your PR review checklist before deploying any MCP server that touches production data:
- Every tool handler emits one structured JSON log entry per invocation, before returning the response
user_idandsession_idcome from the session auth context — never from tool input argumentscredential_reflogs the Vault path, AWS secret ARN, or key name — never the credential valueinput_summaryis sanitized through the allowlist sanitizer — test with a mock that injects a JWT token as an argument and verify it is redacteddata_classesuses the pre-defined taxonomy from your RoPA, populated by a per-tool metadata declaration (not inferred at runtime)- Log storage is append-only: S3 Object Lock, CloudWatch Logs, or a Postgres table with a trigger that raises on UPDATE/DELETE
- Hot tier: 90 days with PII. Warm tier: 12 months without PII. Incident tier: 7 years for flagged sessions
- A cron job runs the hash chain
verify()method nightly and alerts on failure - The two-table erasure pattern is implemented — main chain row, erasable data row linked by event_id
- At least one monitoring alert covers bulk exfiltration (response_bytes SUM) and authorization probing (rejection sequences)
The audit trail as a security signal
Compliance framing aside, a well-designed audit trail is one of the most useful security tools you have for an MCP server. The patterns that precede a security incident — increasing rejection counts, anomalous session data volumes, unusual tool call sequences — are all visible in the audit log before the incident is confirmed. The same data that satisfies your SOC 2 auditor is the data that fires your incident alert. Build it once, use it for both.
If you want to see what SkillAudit finds in an existing MCP server's audit logging implementation — or confirm you've met the checklist above — run a free audit. The Maintenance axis report includes a full audit trail assessment with specific line references to the findings.