mcp-audit-otel
v0.1.0
Published
Vendor-neutral MCP audit-event schema + OpenTelemetry emitter + middleware that maps each Model Context Protocol call to SOC 2, HIPAA, CMMC/NIST 800-171, and DORA controls.
Maintainers
Readme
mcp-audit-otel
A vendor-neutral audit-event schema for Model Context Protocol servers, an OpenTelemetry emitter for it, drop-in middleware for the MCP TypeScript SDK, and a machine-readable catalog mapping each audit event to specific SOC 2 / HIPAA / CMMC (NIST 800-171) / DORA controls.
MCP servers expose tools that read data and execute actions on a user's behalf, but the protocol says nothing about audit logging — there's no standard for what to record or how to structure it. Generic LLM-observability tools (OpenLLMetry, Langfuse, OpenInference) emit OpenTelemetry but carry zero compliance mapping. mcp-audit-otel fills that gap: one typed event shape, emitted as standard OTel, annotated with the real controls each event helps satisfy.
This is not a compliance attestation
The library records an audit stream and maps it to controls. It does not make you SOC 2 / HIPAA / CMMC / DORA compliant. Satisfying a control in full also requires log retention, tamper-proof storage, synchronized clocks, and human review — your responsibility, not the library's. Every control in the catalog carries a
basisflag (records/evidences/feeds) that states honestly how far an audit log actually gets you. See The control catalog.
Install
npm install mcp-audit-otelOpenTelemetry is an optional peer dependency — required only if you use the emitter or middleware (the default mcp-audit-otel entry). Install it alongside an SDK + exporter to actually ship records:
npm install @opentelemetry/api @opentelemetry/api-logs
# plus, in your app, an SDK + exporter to actually ship the records:
npm install @opentelemetry/sdk-logs @opentelemetry/exporter-logs-otlp-httpThe OTel-free subpaths need none of the above: mcp-audit-otel/schema (event types + validators), mcp-audit-otel/catalog (the control catalog), and the mcp-audit-otel CLI all work without OpenTelemetry. The MCP SDK (@modelcontextprotocol/sdk) is likewise an optional peer — needed only for the withAudit middleware.
Quick start
1. Wrap your MCP server
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { withAudit } from "mcp-audit-otel";
const server = new Server({ name: "my-server", version: "1.0.0" }, { capabilities: { tools: {} } });
withAudit(server, {
transport: "tcp", // "pipe" for stdio
getActor: (extra) => (extra as { authInfo?: { clientId?: string } })?.authInfo?.clientId,
// tool/resource params are SHA-256 hashed by default (raw args never emitted).
// The target (tool name / resource URI) is recorded as-is — see Redaction.
});
// tools/call, resources/read, resources/subscribe, prompts/get,
// sampling/createMessage, and elicitation/create are audited automatically.
// NOTE: the SDK registers its own initialize handler in the constructor, so
// session setup is not audited unless you register an initialize handler
// yourself after withAudit (or call recordAuth — see Manual events).
server.setRequestHandler(/* ... */);2. Wire an OTel logs pipeline (your app's bootstrap)
import { LoggerProvider, BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { logs } from "@opentelemetry/api-logs";
const provider = new LoggerProvider({
processors: [new BatchLogRecordProcessor(new OTLPLogExporter())],
});
logs.setGlobalLoggerProvider(provider);Do not route audit records through a head-based trace sampler. Audit events are point-in-time evidence; a dropped record is lost evidence. Send them through a dedicated
LogRecordProcessorwith sampling effectively always-on. From there your OTLP Collector fans out to any backend (Datadog, Grafana, Splunk, stdout) with no code change.
3. Emit events the middleware doesn't cover
withAudit auto-instruments the request methods listed above (and initialize, only if you register your own initialize handler after withAudit, since the SDK pre-registers one). For the rest of the taxonomy — auth, config_change, enumerate, roots_change, logging_failure — emit by hand:
import { recordAuthFailure, recordAuth, recordLoggingFailure, recordAuditEvent } from "mcp-audit-otel";
// Transport-level auth rejection (happens before any handler runs):
recordAuthFailure({ actor_id: "client-9", error_type: "invalid_token", mcp_method: "tools/call" });
// Successful session auth:
recordAuth({ actor_id: "client-9", outcome: "success" });
// Your audit pipeline failed — the CMMC 3.3.4 / DORA RTS Art.12 signal:
recordLoggingFailure({ actor_id: "system", error_type: "exporter_write_failed" });
// Anything else: the generic helper fills schema_version + timestamp, validates, and emits.
recordAuditEvent({
event_name: "mcp.audit.config_change",
outcome: "success",
mcp_method: "tools/call",
actor_id: "admin",
target: "feature_flag.enable",
});Redaction options
Params are SHA-256 hashed by default. Swap the redactor or (carefully) capture raw values:
import { keyDigest } from "mcp-audit-otel";
withAudit(server, {
redactParams: (method, params) => keyDigest(params), // record field NAMES only, no values
// redactTarget: (method, target) => target.split("?")[0], // strip query strings from URIs
// captureRawParams: true, // DANGER — see below
});sha256Digest(default) — one-way hash; identical params hash alike, values never exposed.keyDigest— records only top-level field NAMES (e.g.keys:amount,ssn). The names are cleartext and not one-way; use only when the names themselves aren't sensitive.redactTarget— thetarget(tool name / resource URI) is recorded in cleartext by default and is not covered byredactParams. A resource URI can embed a presigned token or a PII path; use this hook to hash or strip it.captureRawParams: true— DANGER. Disables redaction and writes raw arguments into telemetry, which may persist ePHI/CUI/secrets. Enable only with a documented data-handling justification.
Programmatic validation & reporting
import { validateAuditEvent, safeValidateAuditEvent } from "mcp-audit-otel/schema"; // OTel-free
import { computeCoverage, renderReportMarkdown } from "mcp-audit-otel";
const result = safeValidateAuditEvent(input); // { success, data } | { success: false, error }
const report = computeCoverage(loadedEvents); // control coverage over a captured log
console.log(renderReportMarkdown(report));Why Log Records, not Spans
Audit events are emitted as OTel Log Records (Events) via @opentelemetry/api-logs, not as spans. Spans can be dropped by a head-based sampler — unacceptable for audit evidence — and OTel is deprecating Span Events. Each record instead carries the active traceId/spanId (via the attached context) so it correlates to a trace without living inside one. event_name is the static OTel EventName (low cardinality); per-event identifiers live in attributes.
Fields map onto existing semantic conventions wherever they exist (user.id, user.roles, rpc.*, gen_ai.system/gen_ai.tool.name, network.transport, server.address, error.type, code.function.name); anything without a standard home is namespaced under mcp.audit.*.
The event schema
The audit event (the moat) — six required fields establish accountability, the rest add context. Validated with zod; unknown keys are rejected.
| Field | Req | Purpose |
| --- | --- | --- |
| schema_version | ✓ | Version of this event schema. |
| event_name | ✓ | One of 13 mcp.audit.* types (the taxonomy). |
| timestamp | ✓ | UTC epoch milliseconds. |
| outcome | ✓ | success | failure. |
| mcp_method | ✓ | JSON-RPC method, e.g. tools/call. |
| actor_id | ✓ | Authenticated principal (the "who"). |
| actor_roles | | Roles at event time (least-privilege review). |
| target | | Tool name / resource URI / prompt name. |
| session_id | | Correlates events within a session. |
| params_digest | | Hash/redaction of params — never raw args by default. |
| error_type | | Error class. Required iff outcome = failure. |
| protocol_version | | MCP version negotiated (on initialize). |
| transport | | pipe (stdio) | tcp (HTTP/SSE). |
| server_address | | Server host/IP. |
| trace_id / span_id | | For serialized events; correlation to a trace. |
| code_function | | Handler FQN for forensic correlation (manual / serialized events only — not set by the middleware). |
The control catalog
35 controls across four frameworks, every identifier verified against its authoritative source:
| Framework | Controls | Identifiers | | --- | --- | --- | | SOC 2 (AICPA TSC) | 8 | CC6.1–6.3, CC6.8, CC7.1–7.4 | | HIPAA Security Rule | 6 | 45 CFR 164.312(b), 164.312(c)(2), 164.312(a)(2)(i), 164.308(a)… | | CMMC L2 / NIST 800-171 Rev. 2 | 14 | AU 3.3.1–3.3.9, AC 3.1.7/3.1.12, CM 3.4.3, SI 3.14.6/3.14.7 | | DORA (Reg. EU 2022/2554 + RTS 2024/1774) | 7 | Arts. 9, 10, 17 + RTS Art. 12 |
Per-control authoritative source URLs live in catalog.json and the sourceUrl field.
The machine-readable catalog ships at catalog.json and is importable:
import { getControlsForEvent, getControl } from "mcp-audit-otel/catalog";
getControlsForEvent("mcp.audit.tool_call"); // controls a tool call contributes to
getControl("3.3.1", "CMMC"); // a single control mappingEach control's basis is the integrity feature:
- records — the audit log is the primary artifact the control requires (e.g. CMMC 3.3.1, HIPAA 164.312(b)).
- evidences — the control's real subject is elsewhere (ID assignment, clock-sync, integrity crypto); the log corroborates it.
- feeds — a process you run (review, retention, correlation, alerting) must consume the log to satisfy it.
CLI
# Validate a captured audit log (JSONL, or a single JSON array)
npx mcp-audit-otel validate audit.jsonl
# Generate a control-coverage report (markdown or json)
npx mcp-audit-otel report audit.jsonl --format md
# Inspect the control catalog
npx mcp-audit-otel catalog --format jsonvalidate exits non-zero on any invalid event — drop it into CI over a sampled audit log to catch schema drift.
Compatibility notes
- The OTel
mcp.*andgen_ai.*semantic conventions are still at Development stability and may rename upstream. This package pins the attribute strings and tracks renames through its own changelog rather than importing the volatile constants. - The OTel JS logs SDK is younger than its traces SDK; this package only touches the stable
@opentelemetry/api-logssurface. - For a formal SSP/audit deliverable, re-confirm verbatim control text against the primary source PDFs (linked per control via
sourceUrl).
License
MIT © Kwashawn Warren
