How to audit MCP tool calls

To audit MCP tool calls from Claude Code, turn on the OpenTelemetry logs exporter and set OTEL_LOG_TOOL_DETAILS=1. Each call then produces a tool_decision event, and each call that runs also produces a tool_result event, both naming the server and tool. Add hooks for a local record of each server’s source.
Checked against the Claude Code monitoring and hooks docs on code.claude.com, fetched 2026-10-26. Those pages give no single Claude Code version for the whole document. Where a feature needs one, this post says which.
What an MCP audit record has to answer
An MCP audit trail has to answer five questions: who made the call, which server and tool it reached, what arguments it sent, whether it was allowed and by which decision source, and what came back. Claude Code’s telemetry can answer all five, but no single event does it alone. The table shows which source covers which case.
| Source | Records | Denied calls | Failed calls | Setup |
|---|---|---|---|---|
tool_decision event |
Accept or reject, decision source, server and tool | Yes, decision is reject |
Not applicable | Logs exporter, plus OTEL_LOG_TOOL_DETAILS=1 for names |
tool_result event |
Success, duration, server scope, arguments | No, not emitted for rejected calls | Yes, success is "false" |
Same as above |
mcp_server_connection event |
Connect, disconnect, connection failure | Not applicable | Not applicable | Logs exporter |
PostToolUse hook |
Input and response, server source |
No, fires after success only | No | Hook in a settings file |
PostToolUseFailure hook |
Input for failed calls | No | Yes | Hook in a settings file |
PermissionDenied hook |
Input and reason |
Auto mode denials only | No | Hook in a settings file |
Two rules in the table shape the setup. A rejected call produces no tool_result, so denials come from tool_decision. A failed call still produces a tool_result, with success set to "false".
Step 1: turn on telemetry and the logs exporter
Telemetry needs CLAUDE_CODE_ENABLE_TELEMETRY set, and the monitoring docs list it as required. Set the variables in managed settings, or in each developer’s shell. Claude Code ignores the OpenTelemetry exporter variables in a repository’s .claude/settings.json and .claude/settings.local.json, so a repo cannot turn audit on or send it somewhere else.
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_LOG_TOOL_DETAILS=1
- Put the block above in managed settings, or in each developer’s shell profile.
- Start Claude Code and run a session that calls an MCP tool.
When managed settings set OTEL_EXPORTER_OTLP_ENDPOINT, Claude Code removes every developer-set per-signal endpoint. The exporter selectors, such as OTEL_LOGS_EXPORTER, follow normal precedence instead. A developer’s own setting can still disable the logs, so set the selectors in managed settings too if you need them locked.
Step 2: read the tool_decision and tool_result events
Both events carry tool_use_id, the same ID hook input carries, so you can join a call’s decision, its result and its hook record. The fields you need are below.
| Question | tool_decision |
tool_result |
|---|---|---|
| Was the call allowed? | decision is accept or reject |
decision_type is always accept |
| Where did the decision come from? | source: config, hook, user_permanent, user_temporary, user_abort or user_reject |
decision_source: config, hook, user_permanent or user_temporary |
| Which server and tool? | tool_parameters: mcp_server_name, mcp_tool_name |
tool_parameters: the same two fields |
| Did it run, and did it work? | Not listed | success, duration_ms, error_type |
| What arguments? | tool_parameters |
tool_input |
| Which server scope? | Not listed | mcp_server_scope |
Filter on tool_parameters, not tool_name. For user-configured servers, tool_name is always the literal mcp_tool. The server and tool names appear only in tool_parameters, and only with OTEL_LOG_TOOL_DETAILS=1 set.
Step 3: add a hook for a local record
The events cover most of the audit. Hooks add two things the event attributes above do not list: the server’s source (the docs give plugin, sdk, user and project as examples), and the reason for an auto mode denial. The mcp_server field in hook input requires Claude Code v2.1.274 or later.
Add the hook to ~/.claude/settings.json to log every project on the machine, or to .claude/settings.json to commit it for everyone who clones the repo. Replace the path with your script.
{
"hooks": {
"PostToolUse": [
{
"matcher": "mcp__.*",
"hooks": [{ "type": "command", "command": "/path/to/audit-mcp.sh" }]
}
],
"PostToolUseFailure": [
{
"matcher": "mcp__.*",
"hooks": [{ "type": "command", "command": "/path/to/audit-mcp.sh" }]
}
],
"PermissionDenied": [
{
"matcher": "mcp__.*",
"hooks": [{ "type": "command", "command": "/path/to/audit-mcp.sh" }]
}
]
}
}
The script appends the whole hook input to a file, one JSON object per line. It needs jq on the machine.
#!/bin/sh
# Appends the hook input as one JSON line, with a UTC timestamp.
LOG="$HOME/.claude/logs/mcp-tool-calls.jsonl"
mkdir -p "$(dirname "$LOG")"
jq -c '. + {logged_at: (now | todateiso8601)}' >> "$LOG"
Make the script executable with chmod +x.
What the events and hooks still miss
- Argument size. The docs truncate individual
tool_inputvalues over 512 characters and bound the full payload to about 4 K characters. A long argument in the collector may not match the call that ran. - Subagents. Hooks from settings files run inside subagents too, so the hook log covers their MCP calls.
- Transcripts. The
transcript_pathfile may lag the current turn. Do not use it as the record of the latest call. Use the events or the hook log. - Sensitive data. Arguments and responses can hold secrets or customer data. Decide who can read the collector and the hook log before you turn on
OTEL_LOG_TOOL_DETAILS. That advice is ours, not a documented requirement. - Local hooks. A developer can edit the hook in their own settings, so the hook log shows what that machine recorded. Keep the collector as the record you rely on.
Review denials and failures
A regular query answers the first questions most teams ask. Start with tool_decision events where decision is reject, and group them by mcp_server_name in tool_parameters. That needs OTEL_LOG_TOOL_DETAILS=1. A server with many rejects is either blocked on purpose or misconfigured, and the source field shows whether a setting, a hook or a person made each decision.
Then filter tool_result events where success is "false", and group them by server and error_type. A spike in one error type that stays with one server is a lead to check that server’s configuration. Keep the same grouping for each review so that the numbers stay comparable from one period to the next.
Check the setup
- Start Claude Code with the variables set, and submit a prompt. The monitoring docs say that a logs-only setup shows a
claude_code.user_promptevent. - Ask Claude to call an MCP tool.
- In the collector, find the
tool_decisionandtool_resultevents with the sametool_use_id. Check thattool_parametersnames the server. - Check that the hook log has a line with that
tool_use_id.
Enforcing this with AIControls
AIControls can run Claude Code through its gateway. Claude Code via the gateway (CLI and VS Code) and per-user tokens are both on the AIControls confirmed list. The steps above work without the gateway. This post makes no claim about what AIControls records for each tool call, so check the AIControls product page for that. For the wider picture, read the MCP security guide, and the post on restricting which MCP servers Claude Code can use.
Frequently asked questions
Does Claude Code log MCP tool calls without extra configuration?
Telemetry needs CLAUDE_CODE_ENABLE_TELEMETRY=1 first, and the monitoring docs list it as required. Even then, MCP detail is off by default. Without OTEL_LOG_TOOL_DETAILS=1, the tool_name of a user-configured server is recorded as the literal mcp_tool, and argument content is left out. With the flag on, the server and tool names appear in tool_parameters.
Are denied MCP tool calls recorded?
Yes, in the tool_decision event, where decision is reject. A rejected call produces no tool_result. The PermissionDenied hook also fires, but only when auto mode denies a call. It does not run when you deny a permission prompt yourself, when a PreToolUse hook blocks a call, or when a deny rule matches. Use tool_decision as the main record of denials.
Why does my MCP audit show mcp_tool instead of the tool name?
That is the redaction the docs describe for user-configured servers. The event's tool_name is always the literal mcp_tool. The server and tool names are in tool_parameters, as mcp_server_name and mcp_tool_name, and only with OTEL_LOG_TOOL_DETAILS=1. Filter on those two fields in your collector, not on tool_name.
Can a developer turn off MCP audit logging on their machine?
They can, unless you lock it in managed settings. A developer's own setting can still disable a signal or switch it to the console exporter. A repository's .claude/settings.json cannot set the exporter variables at all. A hook lives in a settings file the developer can edit, so keep the collector as the record you rely on and treat the hook log as a local copy.
Do hooks see MCP calls made by subagents?
Yes. Hooks from settings files, managed policy settings and plugins also run inside subagents. When a subagent calls a tool, PreToolUse and PostToolUse fire the same configured hooks as in the main conversation. A matcher of mcp__.* covers MCP tools in those subagent calls too.