Control agent access
How to export MCP tool call logs to your SIEM
Claude Code can emit an OpenTelemetry event for every tool call it makes, and an OpenTelemetry Collector can trim those events and forward them to whichever SIEM you already run.
New to MCP? What is an MCP server? covers the basics first.
You'll run a collector on your machine, point Claude Code at it, watch a real MCP tool call arrive, and then send the same stream to your SIEM. Splunk, Datadog, Elastic and Microsoft Sentinel each get their exporter config.
Prerequisites
- Claude Code. I ran 2.1.322 (
claude --version). - Docker, to run the collector. I used the
otel/opentelemetry-collector-contribimage, version 0.165.0. - Node.js 20 or later, for the test MCP server.
- For the last step, a SIEM you can write to and its ingest credentials.
Step 1: Decide which fields a record needs
A tool call record has to answer who called what, with what, and whether it worked. Here is what to keep, and where each field comes from in Claude Code's events:
| Field | Claude Code attribute |
|---|---|
| Who made the call | user.account_uuid, user.email, session.id |
| Server and tool | mcp_server_name and mcp_tool_name inside tool_parameters |
| Arguments | tool_input, if your policy allows storing them |
| Result | success, plus error_type when it failed |
| Duration | duration_ms |
| Allowed or refused | decision and source on the tool_decision event |
Two of these need care. The caller fields name the person's Claude account, so an agent running on someone's login logs as that person. And tool_name is the literal mcp_tool for every MCP call. The server and tool names only appear with OTEL_LOG_TOOL_DETAILS=1, which also turns on tool_input and full Bash commands. Step 5 strips what you don't want. What to log when AI agents call tools goes through the reasoning behind each field.
Step 2: Turn on Claude Code's events
These are the variables from Anthropic's monitoring docs. Telemetry is off until the first one is set:
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_LOGS_EXPORTER=otlp
export OTEL_METRICS_EXPORTER=none
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
export OTEL_LOG_TOOL_DETAILS=1
export OTEL_LOGS_EXPORT_INTERVAL=1000Claude Code has no default protocol, so OTEL_EXPORTER_OTLP_PROTOCOL is required. The export interval defaults to 5,000 ms. I lowered it so events show up while you watch.
For a team, put the same keys in the env block of Claude Code's managed settings file. When managed settings set an OTEL_EXPORTER_OTLP_* variable, Claude Code removes a developer's conflicting value at startup, and it ignores these variables in a repository's .claude/settings.json. A developer can still switch a signal off or to the console, so set OTEL_LOGS_EXPORTER in managed settings too.
Step 3: Run a collector that writes to a file
Save this as collector.yaml. It accepts OTLP and writes every log record to the console and to a file:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
exporters:
debug:
verbosity: detailed
file:
path: /out/claude-code-logs.json
service:
pipelines:
logs:
receivers: [otlp]
processors: [batch]
exporters: [debug, file]Start it, with a writable out folder next to the config:
mkdir -p out && chmod 777 out
docker run -d --name otel -p 4317:4317 -p 4318:4318 \
-v "$PWD/collector.yaml:/etc/otelcol-contrib/config.yaml" \
-v "$PWD/out:/out" \
otel/opentelemetry-collector-contrib:latest
docker logs otel 2>&1 | tail -1The last line should end with Everything is ready. Begin running and processing data.
Step 4: Make a tool call and read the event
Give Claude Code one MCP server in an mcp.json:
{
"mcpServers": {
"everything": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"]
}
}
}Then, in the shell where you exported the variables:
claude -p "Call the echo tool from the everything MCP server with the message 'hello siem', then stop." \
--mcp-config mcp.json --strict-mcp-config --allowedTools "mcp__everything__echo"docker logs otel now has a claude_code.tool_result record. This is mine, with the account identifiers taken out:
Body: Str(claude_code.tool_result)
Attributes:
-> session.id: Str(8c9144c3-b82c-46c0-9251-de041efcac7a)
-> event.name: Str(tool_result)
-> event.timestamp: Str(2026-11-12T00:52:51.661Z)
-> tool_name: Str(mcp_tool)
-> tool_use_id: Str(toolu_01H9asfBh4ZDcPbyHDRZAYC7)
-> success: Str(true)
-> duration_ms: Str(8)
-> tool_parameters: Str({"mcp_server_name":"everything","mcp_tool_name":"echo"})
-> tool_input: Str({"message":"hello siem"})
-> tool_input_size_bytes: Str(24)
-> tool_result_size_bytes: Str(43)
-> mcp_server_scope: Str(dynamic)Run the same command without --allowedTools and the call is refused. That produces a tool_decision event with decision: reject and source: config, and no tool_result at all. Your SIEM queries for refusals have to look at tool_decision.
The resource on every record carries service.name: claude-code and service.version, which is what to filter on once other services share the collector.
Step 5: Keep only tool events and drop the arguments
Claude Code also sends prompts, API requests, hook runs and connection events through the same pipeline. Add two processors to keep the tool events and remove tool_input:
processors:
filter/tool_events:
error_mode: ignore
log_conditions:
- 'not (log.attributes["event.name"] == "tool_result" or log.attributes["event.name"] == "tool_decision")'
transform/redact:
error_mode: ignore
log_statements:
- delete_key(log.attributes, "tool_input")
- set(log.attributes["user.email"], SHA256(log.attributes["user.email"])) where log.attributes["user.email"] != nil
batch:and list them in the pipeline, processors: [filter/tool_events, transform/redact, batch]. A filter condition that matches drops the record, hence the not. After docker restart otel and another run, the file held only tool_decision and tool_result records, none had tool_input, and user.email was a 64-character hash. Leave the hash line out if your analysts need to search by email.
Whether to keep arguments is a policy call. They are the most useful field in an investigation, and a tool's input can hold a customer's data or a secret someone pasted. The MCP specification's logging page says log messages "MUST NOT contain" credentials or personal information, and that is a sensible default for an export too.
Step 6: Send it to your SIEM
Swap the file exporter for your SIEM's. Each block below is from the exporter's own documentation, and all four passed otelcol-contrib validate on 0.165.0. I did not send data to any of them, so test with your own account before you trust it.
Splunk, through the HTTP Event Collector:
exporters:
splunk_hec:
token: ${env:SPLUNK_HEC_TOKEN}
endpoint: https://splunk.example.com:8088/services/collector
source: claude-code
sourcetype: claude_code:tool
index: ai_agentsDatadog, which documents sending OTLP straight to its intake:
exporters:
otlp_http/datadog:
endpoint: https://otlp.${env:DD_SITE}
headers:
dd-api-key: ${env:DD_API_KEY}Elastic:
exporters:
elasticsearch:
endpoint: https://elastic.example.com:9200
api_key: ${env:ELASTIC_API_KEY}Microsoft Sentinel reads from a Log Analytics workspace, so the route is Azure Monitor's OTLP ingestion with a data collection rule that writes to the workspace Sentinel runs on. Microsoft's collector guide shows how to build the URL. The collector signs in with the azure_auth extension, and its identity needs the Monitoring Metrics Publisher role on the rule:
extensions:
azure_auth:
use_default: true
scopes:
- https://monitor.azure.com/.default
exporters:
otlp_http/sentinel:
logs_endpoint: https://<dce-domain>/datacollectionRules/<dcr-immutable-id>/streams/Microsoft-OTLP-Logs/otlp/v1/logs
auth:
authenticator: azure_auth
service:
extensions: [azure_auth]Microsoft's page spells the exporter otlphttp. Collector 0.165.0 accepted both spellings.
Keep every token in an environment variable as shown. A collector config is one more file people commit.
What the client's events can't tell you
This setup records what Claude Code reports about itself. A developer who runs it without your managed settings sends nothing, and other MCP clients don't emit these events. A server can log its own calls too, but every server does that differently, and the MCP logging utility sends messages from the server to the client, where they stay.
A gateway logs on the server side of the connection, so the record exists whichever client made the call. That holds as long as the gateway is the only route to the upstream API, which is the case when it holds the keys.
Statio keeps two logs at its gateway. Activity has the tool, server, caller (user, agent or unattributed), result, status code and latency of every call, refusals included. Audit Logs record every change to who can call what. From the Business plan up, webhooks deliver either log or both to your SIEM, signed with HMAC in the X-Statio-Signature and X-Statio-Timestamp headers, and admins can export them as CSV or NDJSON. Request and response bodies aren't stored, so there are no arguments to redact.
Conclusion
You have a collector that receives Claude Code's tool events, keeps the ones an audit needs, removes the arguments, and forwards them to Splunk, Datadog, Elastic or Sentinel.
Related reading: what to log when AI agents call tools for the field choices, AI agent identity for getting an agent its own name in the caller field, and MCP audit logs for how Statio records calls at the gateway.