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.

By
Dan Flanagan
Updated
November 12, 2026
Tested with
Claude Code 2.1.322 and OpenTelemetry Collector Contrib 0.165.0 on macOS 26.6, with a local collector writing to a file; the four SIEM exporter configs were checked with otelcol-contrib validate but not sent to a live Splunk, Datadog, Elastic or Sentinel account
Reading time
5 min

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-contrib image, 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:

Fields for a tool call record and the Claude Code attributes that carry them
FieldClaude Code attribute
Who made the calluser.account_uuid, user.email, session.id
Server and toolmcp_server_name and mcp_tool_name inside tool_parameters
Argumentstool_input, if your policy allows storing them
Resultsuccess, plus error_type when it failed
Durationduration_ms
Allowed or refuseddecision 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:

bash
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=1000

Claude 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:

yaml
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:

bash
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 -1

The 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:

json
{
  "mcpServers": {
    "everything": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-everything"]
    }
  }
}

Then, in the shell where you exported the variables:

bash
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:

text
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:

yaml
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:

yaml
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_agents

Datadog, which documents sending OTLP straight to its intake:

yaml
exporters:
  otlp_http/datadog:
    endpoint: https://otlp.${env:DD_SITE}
    headers:
      dd-api-key: ${env:DD_API_KEY}

Elastic:

yaml
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:

yaml
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.