Audit logging¶
mocapi-audit emits one structured log event per MCP handler invocation on a
dedicated SLF4J logger — mocapi.audit — suitable for compliance queries,
SIEM pipelines, and "who called what, when, with what outcome" dashboards.
Activation¶
<dependency>
<groupId>com.callibrity.mocapi</groupId>
<artifactId>mocapi-audit</artifactId>
</dependency>
That's it. The autoconfig attaches one AuditLoggingInterceptor per
tool / prompt / resource / resource-template via the per-handler
customizer SPI; no mocapi-API calls required.
To silence audit temporarily without removing the module, set
logging.level.mocapi.audit=OFF.
Field vocabulary¶
| Key | Always present? | Value |
|---|---|---|
caller |
yes | Authenticated principal name from the pluggable AuditCallerIdentityProvider, or anonymous. |
protocol_version |
yes | Protocol version from the request's _meta envelope. |
client_name |
yes (may be null) |
Client name from the envelope's clientInfo. |
handler_kind |
yes | One of tool, prompt, resource, resource_template. |
handler_name |
yes | Tool / prompt name, or resource URI / URI template. |
outcome |
yes | One of success, forbidden, invalid_params, error. |
duration_ms |
yes | Wall-clock invocation duration, in integer milliseconds. |
arguments_hash |
only when mocapi.audit.hash-arguments=true |
SHA-256 over the key-sorted canonical JSON of the arguments, prefixed sha256:. |
error_class |
only on failed invocations | Simple name of the thrown exception class. |
All fields use snake_case so dashboards and SQL-over-log tools can reference
them without Java camelCase bleeding in. The constants live on
com.callibrity.mocapi.audit.AuditFieldKeys.
Outcome classification¶
| Value | Trigger |
|---|---|
success |
The invocation completed without an infrastructure-level exception. A tool returning CallToolResult.isError=true still counts as success here — it's a model-visible tool error, not an audit-level failure. |
forbidden |
A JsonRpcException with code -32010 Forbidden (a guard denial — see mocapi-spring-security-guards). |
invalid_params |
A JsonRpcException with code -32602 Invalid params (e.g., Jakarta Validation rejected the inputs). |
error |
Any other thrown exception. Stack traces are not emitted — only error_class. |
Caller identity¶
AuditCallerIdentityProvider is a single-method SPI:
String currentCaller();
The default bean:
- When Spring Security is on the classpath —
DefaultAuditCallerIdentityProviderreadsSecurityContextHolder.getContext().getAuthentication().getName()and falls back to"anonymous"when nothing is authenticated. - When Spring Security is absent — a trivial provider that always returns
"anonymous".
Override either default by declaring your own AuditCallerIdentityProvider
bean:
@Bean
AuditCallerIdentityProvider tenantScopedCaller(TenantContext tenants) {
return () -> tenants.current().map(t -> t.id() + ":" + t.userId()).orElse("anonymous");
}
The default is @ConditionalOnMissingBean, so your bean wins.
Arguments hash¶
Off by default. Turn it on with
mocapi.audit.hash-arguments=true
When enabled, the interceptor serializes the arguments to key-sorted canonical
JSON and emits sha256:<hex> as arguments_hash. This is enough to correlate
"did these two calls pass identical inputs?" without persisting (or
transmitting) the input values themselves.
Even a hash can be a weak fingerprint of sensitive inputs (e.g., if the input space is small and known, a hash is effectively a label), which is why this is an opt-in. If hashing doesn't meet your threat model, leave it off.
Routing to a file / SIEM¶
Audit events use the logger name mocapi.audit. Routing them somewhere
separate from the app log is a two-step Logback config:
<appender name="AUDIT" class="ch.qos.logback.core.FileAppender">
<file>audit.log</file>
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<logger name="mocapi.audit" level="INFO" additivity="false">
<appender-ref ref="AUDIT"/>
</logger>
additivity="false" keeps audit events out of the root appender so they don't
intermingle with ordinary log lines.
With logstash-logback-encoder
installed, the structured fields flow into the JSON document automatically:
{
"@timestamp": "2026-04-19T17:22:11.043Z",
"level": "INFO",
"logger_name": "mocapi.audit",
"message": "mcp.audit",
"caller": "alice",
"protocol_version": "2026-07-28",
"client_name": "ExampleClient",
"handler_kind": "tool",
"handler_name": "get_weather",
"outcome": "success",
"duration_ms": 42
}
With a plain console encoder the same fields render as key=value tails on
each line:
2026-04-19T17:22:11.043Z INFO mocapi.audit - mcp.audit caller=alice protocol_version=2026-07-28 client_name=ExampleClient handler_kind=tool handler_name=get_weather outcome=success duration_ms=42
Interceptor ordering¶
mocapi-audit contributes its interceptor to the AUDIT stratum of the
handler chain (see customizers.md for the full
stratum model). The outer-to-inner sequence around it is:
- CORRELATION (MDC) — stamps
mcp.protocol.version/mcp.client.name/mcp.handler.kind/mcp.handler.name/mcp.request.id. - OBSERVATION (Micrometer observation) — wraps the rest.
- AUDIT (this module) — records the attempt, times it end-to-end. Sees post-guard outcomes, so a denial surfaces as
outcome=forbidden. - AUTHORIZATION — guards evaluated here; denial short-circuits with
-32010. - VALIDATION — JSON input-schema for tools first (rejections surface as
outcome=invalid_params), then user-contributed validation (e.g. Jakarta Bean Validation). - INVOCATION — any user-contributed invocation interceptors, then the reflective method call.
Because AUDIT sits outer of AUTHORIZATION, audit records every attempt — allowed or forbidden — and never has to reason about whether the guard fired. If you want a user interceptor that runs outside the audit scope (e.g., one that logs before audit kicks in), contribute it to CORRELATION or OBSERVATION instead.
What is deliberately NOT emitted¶
- Full argument payloads. PII surface.
- Full response payloads. Same reason.
- Stack traces. Class name only — audit is for compliance queries, not debugging.
If you need the full payloads, write your own customizer-attached interceptor. The audit module's scope is intentionally compliance-shaped.
Query examples¶
Splunk — failed tool calls by caller, last 24h:
index=mocapi source=audit.log outcome!="success" handler_kind="tool"
| stats count by caller, handler_name, outcome
ELK — slowest tools, top 10:
logger_name:"mocapi.audit" AND handler_kind:"tool"
| GROUP BY handler_name
| AGGREGATE p95(duration_ms) AS p95
| SORT p95 DESC LIMIT 10
CloudWatch Insights — guard denials per caller:
fields caller, handler_name, outcome
| filter logger_name = "mocapi.audit" and outcome = "forbidden"
| stats count() as denials by caller, handler_name
| sort denials desc