>

Audit logging for MCP tool calls: what to record, how to store it, and how to query it

Audit logging for MCP tool calls: what to record, how to store it, and how to query it

Tool calls are cross-system actions. When Claude calls notion_search or snowflake_query or github_create_pull_request, something happens outside your application boundary: data is retrieved, records are modified, external state changes. Standard application logs capture what your code does; they don't capture what your AI's tools do. That's a different audit surface, and it needs its own log.

This chapter covers what we record for every MCP tool call, how we store it, and how we structure it so it's queryable when you need it (which is usually under pressure, not in advance).

Solutions to consider

The most common starting point is server-side logging: relying on the MCP server itself to log tool calls. Some servers do this, most don't, and none of them do it consistently or in a format you control. You don't own the server's log pipeline, so you can't guarantee retention, format, or access.

Application-layer logging (logging tool calls from within your own application code) is more reliable but creates the same fragmentation problem as credentials: each service logs differently, attribution is scattered, and there's no single place to query across all tool usage. It also means every service has to implement logging correctly, which in practice means some don't.

Aptible’s solution: proxy-layer logging

The approach we took is proxy-layer logging: every tool call passes through the gateway, and the gateway writes the log record before proxying the call upstream. One enforcement point, one log format, one place to query. The tradeoff is that you need the gateway infrastructure, which is what the rest of this guide describes. If you already have that from Tool-level access control, the logging layer comes essentially for free.

What your MCP audit log should record

This is a general pattern that applies regardless of implementation stack. A useful MCP audit record needs to answer four questions after the fact: who made the call, what did they call, what did they pass to it, and when did it happen from where. That maps to a specific set of fields.

User identity. Not just a session token, a resolved identity that can be tied to a real person or agent. We store both the user ID (from the JWT or robot API key) and the email address for human users, since email is what security reviewers ask for. For robot users, the email field is empty and the user ID identifies the agent.

Tool name. The namespaced tool name: notion_search, not just search. Namespacing by server prevents ambiguity when multiple servers expose similarly named tools, and makes log queries straightforward without needing to join on the server record.

Server identity (two fields). We store both a foreign key to the server record and a denormalized snapshot of the server name. The FK supports joins and aggregation while the server exists; the snapshot survives if the server is deleted or renamed. An audit record that loses its server context when a server is removed is less useful than one that preserves it.

Arguments. What was actually passed to the tool. This is the most sensitive field in the record; a snowflake_query call contains the actual query, a notion_search call may contain proprietary terms. Arguments should be encrypted at rest.

Timestamp. Creation time of the log record, not the time the upstream call returned. If the upstream call fails after the log is written, you still have the record.

IP address. The client IP, resolved from X-Forwarded-For or X-Real-IP for deployments behind a load balancer or reverse proxy. Useful for anomaly detection and incident response.

User agent. Which client made the call: Claude Desktop, Claude Code, a custom agent, or a direct API call. Useful for filtering and for detecting calls made outside expected client paths.

Preserving server context: the snapshot pattern

This is a general pattern that applies to any audit log referencing a mutable resource. The server foreign key in the audit record is useful while the server exists: you can join against it to get the server's current metadata, aggregate usage by server, and cross-reference with the grants table. But server records get deleted; when a team removes an integration, or renames a server, the FK becomes null or points to the wrong name.

The snapshot field (server_name) is a denormalized string copy of the server name at the time the log record was written. It doesn't change when the server is deleted or renamed. For forensics (especially for records that need to be meaningful months or years later) the snapshot is the authoritative field, not the FK.

async def create_audit_log(
    *,
    claims: dict,
    org_id: str,
    tool_name: str,
    arguments: dict,
    user_agent: str,
    ip_address: str | None,
) -> None:
    try:
        server = McpServer.resolve(tool_name, org_id)
        McpAuditLog.objects.create(
            user_id=claims["id"],
            email=claims.get("email", ""),
            organization_id=org_id,
            tool_name=tool_name,
            arguments=arguments,          # encrypted by EncryptedJSONField
            user_agent=user_agent,
            ip_address=ip_address or None,
            server=server,                # FK, may become null if server is deleted
            server_name=server.name if server else "",  # snapshot, always preserved
        )
    except Exception:
        logger.exception(f"Failed to write audit log for tool={tool_name}")

Write the log before you proxy, not after

This ordering principle applies to any proxy-layer implementation. The audit log record is written before the tool call is proxied to the upstream server, not after.

If the upstream call fails after the log is written, the record still exists -- you know the call was attempted, with what arguments, by whom. If the log write fails, the proxy call doesn't proceed silently; the error surfaces. This asymmetry is intentional: it's better to have a log record for a call that didn't complete than to have a completed call with no record.

async def on_call_tool(self, context, call_next):
    auth = _request_auth.get()
    claims, org_id, allowed = auth

    # Enforcement: reject before logging or proxying
    if context.message.name not in allowed:
        return ToolResult(content=[TextContent(type="text", text="Tool not permitted")])

    # Write the log before proxying
    await create_audit_log(
        claims=claims,
        org_id=org_id,
        tool_name=context.message.name,
        arguments=getattr(context.message, "arguments", {}) or {},
        user_agent=get_http_headers(include={"user-agent"}).get("user-agent", ""),
        ip_address=_get_client_ip(),
    )

    # Then proxy to the upstream server
    server, config = await resolve_server_config(
        context.message.name, org_id, claims["id"]
    )
    async with Client(transport) as client:
        result = await client.call_tool(bare_tool, context.message.arguments or {})
        return ToolResult(content=result.content)

Structuring your audit logs for investigation

A log that exists but can't be searched is only marginally better than no log. The fields described above are the fields you'll filter on during an investigation. The examples below use Django ORM syntax; the filter logic translates directly to any query layer: SQL, Prisma, ActiveRecord, GORM, or otherwise.

The queries you'll actually run:

# What did this user call in the last 30 days?
McpAuditLog.objects.filter(
    organization_id=org_id,
    user_id=user_id,
    created_at__gte=timezone.now() - timedelta(days=30),
).order_by("-created_at")

# What has been called against the Snowflake server?
McpAuditLog.objects.filter(
    organization_id=org_id,
    server_name="snowflake",
).order_by("-created_at")

# Which users called this specific tool?
McpAuditLog.objects.filter(
    organization_id=org_id,
    tool_name="snowflake_query",
).values("email", "user_id").annotate(call_count=Count("id"))

# What happened in a specific time window?
McpAuditLog.objects.filter(
    organization_id=org_id,
    created_at__gte=incident_start,
    created_at__lte=incident_end,
).order_by("created_at")


Index on (organization_id, created_at) as a minimum. Add indexes on tool_name and server_name if you're running frequent analytical queries against those fields. For larger deployments, user_id is worth indexing separately.

The Day-Zero Normal CISO brief recommends establishing behavioral baselines for every autonomous agent with production access and alerting on deviation. The audit log is the data source for that: once you have a record of normal call patterns for a given agent role, deviations become detectable. That use case requires the log to be queryable, not just to exist.

How to handle retention

How long you keep audit logs depends on regulatory requirements and your own incident response posture. For teams in regulated industries, HIPAA requires audit log retention for six years from the date of creation or the date the record was last in effect. See Audit log retention for the full requirements.

For teams outside regulated industries, we recommend a minimum of 90 days for operational purposes (long enough to investigate most incidents) and at least one year for teams running agents with production write access. The argument for longer retention: the median time between a breach and its detection is still measured in weeks to months. A log that expired before you knew to look at it isn't useful.

The cost argument for longer retention is weak. Log storage at rest is cheap. The decision to keep less is almost always a configuration default, not a deliberate tradeoff.

FAQs

Do MCP servers log tool calls themselves?

Some do, most don't, and none do it in a format you control. Even for servers that log internally, you don't have access to those logs, can't guarantee their retention, and can't query across servers from a single place. Proxy-layer logging is the approach that gives you consistent coverage regardless of what individual servers do.

What if I don't have a proxy layer yet?

You can implement basic logging in your own application code as a starting point: log the tool name, timestamp, and user identity before calling the gateway. It won't give you arguments encryption or the snapshot pattern, but it's better than nothing. The proxy-layer approach described here is the more complete solution, and the other chapters in this guide describe how to build it.

Should arguments always be encrypted?

Yes, unless you have a specific reason not to. Arguments routinely contain query strings, search terms, and input data that may include sensitive content. The cost of encryption is low; the cost of a plaintext argument log being exposed is high. Encrypt by default and decrypt only for authorized review.

How do I handle audit log access for a security review?

Provide a read-only interface filtered to the organization and time range in scope. Don't give reviewers direct database access. Decrypted arguments should only be exposed through an authorized review workflow, not as a default in the query interface.

What's the difference between an audit log and application logs?

Application logs capture what your code does. Audit logs capture what identities do: specifically, what actions they took, on what resources, at what time. MCP audit logs are a specific case of the latter: they record which identity called which tool, with what arguments, when. The audience for application logs is your engineering team debugging a problem; the audience for audit logs is your security team, compliance reviewers, and incident responders.

Should I also log tool call results?

It depends on your use case and data sensitivity. Logging results (what the tool returned) gives you a more complete forensic picture; you can see not just what was requested but what data was actually retrieved. The tradeoff is that tool results often contain sensitive content directly: a snowflake_query result may return rows of customer data, a notion_get_page result may contain proprietary documents. If you log results, apply the same encryption treatment as arguments, be explicit about retention policy, and restrict access carefully. Many teams start by logging arguments only and add result logging later for specific high-risk servers where the full audit trail justifies the storage and data handling overhead.

Next steps

If you're managing how users authenticate to MCP servers: MCP authentication: bearer tokens, OAuth, shared vs. personal credentials, and token lifecycle

If you need agents to appear separately in the audit log: Agent identity and robot users: giving non-human principals their own identity so tool calls are attributed to the agent, not the engineer who deployed it

If you're in a regulated industry and need to understand retention requirements: Audit log retention: HIPAA retention requirements and how Aptible covers them

Aptible MCP Gateway gives engineering teams tool-level access control, audit logging, and centralized credential management for MCP without building the proxy infrastructure yourself. Deployed alongside Aptible LLM Gateway, it covers both LLM and tool call governance in one place.

>

text

Audit logging for MCP tool calls: what to record, how to store it, and how to query it

Tool calls are cross-system actions. When Claude calls notion_search or snowflake_query or github_create_pull_request, something happens outside your application boundary: data is retrieved, records are modified, external state changes. Standard application logs capture what your code does; they don't capture what your AI's tools do. That's a different audit surface, and it needs its own log.

This chapter covers what we record for every MCP tool call, how we store it, and how we structure it so it's queryable when you need it (which is usually under pressure, not in advance).

Solutions to consider

The most common starting point is server-side logging: relying on the MCP server itself to log tool calls. Some servers do this, most don't, and none of them do it consistently or in a format you control. You don't own the server's log pipeline, so you can't guarantee retention, format, or access.

Application-layer logging (logging tool calls from within your own application code) is more reliable but creates the same fragmentation problem as credentials: each service logs differently, attribution is scattered, and there's no single place to query across all tool usage. It also means every service has to implement logging correctly, which in practice means some don't.

Aptible’s solution: proxy-layer logging

The approach we took is proxy-layer logging: every tool call passes through the gateway, and the gateway writes the log record before proxying the call upstream. One enforcement point, one log format, one place to query. The tradeoff is that you need the gateway infrastructure, which is what the rest of this guide describes. If you already have that from Tool-level access control, the logging layer comes essentially for free.

What your MCP audit log should record

This is a general pattern that applies regardless of implementation stack. A useful MCP audit record needs to answer four questions after the fact: who made the call, what did they call, what did they pass to it, and when did it happen from where. That maps to a specific set of fields.

User identity. Not just a session token, a resolved identity that can be tied to a real person or agent. We store both the user ID (from the JWT or robot API key) and the email address for human users, since email is what security reviewers ask for. For robot users, the email field is empty and the user ID identifies the agent.

Tool name. The namespaced tool name: notion_search, not just search. Namespacing by server prevents ambiguity when multiple servers expose similarly named tools, and makes log queries straightforward without needing to join on the server record.

Server identity (two fields). We store both a foreign key to the server record and a denormalized snapshot of the server name. The FK supports joins and aggregation while the server exists; the snapshot survives if the server is deleted or renamed. An audit record that loses its server context when a server is removed is less useful than one that preserves it.

Arguments. What was actually passed to the tool. This is the most sensitive field in the record; a snowflake_query call contains the actual query, a notion_search call may contain proprietary terms. Arguments should be encrypted at rest.

Timestamp. Creation time of the log record, not the time the upstream call returned. If the upstream call fails after the log is written, you still have the record.

IP address. The client IP, resolved from X-Forwarded-For or X-Real-IP for deployments behind a load balancer or reverse proxy. Useful for anomaly detection and incident response.

User agent. Which client made the call: Claude Desktop, Claude Code, a custom agent, or a direct API call. Useful for filtering and for detecting calls made outside expected client paths.

Preserving server context: the snapshot pattern

This is a general pattern that applies to any audit log referencing a mutable resource. The server foreign key in the audit record is useful while the server exists: you can join against it to get the server's current metadata, aggregate usage by server, and cross-reference with the grants table. But server records get deleted; when a team removes an integration, or renames a server, the FK becomes null or points to the wrong name.

The snapshot field (server_name) is a denormalized string copy of the server name at the time the log record was written. It doesn't change when the server is deleted or renamed. For forensics (especially for records that need to be meaningful months or years later) the snapshot is the authoritative field, not the FK.

async def create_audit_log(
    *,
    claims: dict,
    org_id: str,
    tool_name: str,
    arguments: dict,
    user_agent: str,
    ip_address: str | None,
) -> None:
    try:
        server = McpServer.resolve(tool_name, org_id)
        McpAuditLog.objects.create(
            user_id=claims["id"],
            email=claims.get("email", ""),
            organization_id=org_id,
            tool_name=tool_name,
            arguments=arguments,          # encrypted by EncryptedJSONField
            user_agent=user_agent,
            ip_address=ip_address or None,
            server=server,                # FK, may become null if server is deleted
            server_name=server.name if server else "",  # snapshot, always preserved
        )
    except Exception:
        logger.exception(f"Failed to write audit log for tool={tool_name}")

Write the log before you proxy, not after

This ordering principle applies to any proxy-layer implementation. The audit log record is written before the tool call is proxied to the upstream server, not after.

If the upstream call fails after the log is written, the record still exists -- you know the call was attempted, with what arguments, by whom. If the log write fails, the proxy call doesn't proceed silently; the error surfaces. This asymmetry is intentional: it's better to have a log record for a call that didn't complete than to have a completed call with no record.

async def on_call_tool(self, context, call_next):
    auth = _request_auth.get()
    claims, org_id, allowed = auth

    # Enforcement: reject before logging or proxying
    if context.message.name not in allowed:
        return ToolResult(content=[TextContent(type="text", text="Tool not permitted")])

    # Write the log before proxying
    await create_audit_log(
        claims=claims,
        org_id=org_id,
        tool_name=context.message.name,
        arguments=getattr(context.message, "arguments", {}) or {},
        user_agent=get_http_headers(include={"user-agent"}).get("user-agent", ""),
        ip_address=_get_client_ip(),
    )

    # Then proxy to the upstream server
    server, config = await resolve_server_config(
        context.message.name, org_id, claims["id"]
    )
    async with Client(transport) as client:
        result = await client.call_tool(bare_tool, context.message.arguments or {})
        return ToolResult(content=result.content)

Structuring your audit logs for investigation

A log that exists but can't be searched is only marginally better than no log. The fields described above are the fields you'll filter on during an investigation. The examples below use Django ORM syntax; the filter logic translates directly to any query layer: SQL, Prisma, ActiveRecord, GORM, or otherwise.

The queries you'll actually run:

# What did this user call in the last 30 days?
McpAuditLog.objects.filter(
    organization_id=org_id,
    user_id=user_id,
    created_at__gte=timezone.now() - timedelta(days=30),
).order_by("-created_at")

# What has been called against the Snowflake server?
McpAuditLog.objects.filter(
    organization_id=org_id,
    server_name="snowflake",
).order_by("-created_at")

# Which users called this specific tool?
McpAuditLog.objects.filter(
    organization_id=org_id,
    tool_name="snowflake_query",
).values("email", "user_id").annotate(call_count=Count("id"))

# What happened in a specific time window?
McpAuditLog.objects.filter(
    organization_id=org_id,
    created_at__gte=incident_start,
    created_at__lte=incident_end,
).order_by("created_at")


Index on (organization_id, created_at) as a minimum. Add indexes on tool_name and server_name if you're running frequent analytical queries against those fields. For larger deployments, user_id is worth indexing separately.

The Day-Zero Normal CISO brief recommends establishing behavioral baselines for every autonomous agent with production access and alerting on deviation. The audit log is the data source for that: once you have a record of normal call patterns for a given agent role, deviations become detectable. That use case requires the log to be queryable, not just to exist.

How to handle retention

How long you keep audit logs depends on regulatory requirements and your own incident response posture. For teams in regulated industries, HIPAA requires audit log retention for six years from the date of creation or the date the record was last in effect. See Audit log retention for the full requirements.

For teams outside regulated industries, we recommend a minimum of 90 days for operational purposes (long enough to investigate most incidents) and at least one year for teams running agents with production write access. The argument for longer retention: the median time between a breach and its detection is still measured in weeks to months. A log that expired before you knew to look at it isn't useful.

The cost argument for longer retention is weak. Log storage at rest is cheap. The decision to keep less is almost always a configuration default, not a deliberate tradeoff.

FAQs

Do MCP servers log tool calls themselves?

Some do, most don't, and none do it in a format you control. Even for servers that log internally, you don't have access to those logs, can't guarantee their retention, and can't query across servers from a single place. Proxy-layer logging is the approach that gives you consistent coverage regardless of what individual servers do.

What if I don't have a proxy layer yet?

You can implement basic logging in your own application code as a starting point: log the tool name, timestamp, and user identity before calling the gateway. It won't give you arguments encryption or the snapshot pattern, but it's better than nothing. The proxy-layer approach described here is the more complete solution, and the other chapters in this guide describe how to build it.

Should arguments always be encrypted?

Yes, unless you have a specific reason not to. Arguments routinely contain query strings, search terms, and input data that may include sensitive content. The cost of encryption is low; the cost of a plaintext argument log being exposed is high. Encrypt by default and decrypt only for authorized review.

How do I handle audit log access for a security review?

Provide a read-only interface filtered to the organization and time range in scope. Don't give reviewers direct database access. Decrypted arguments should only be exposed through an authorized review workflow, not as a default in the query interface.

What's the difference between an audit log and application logs?

Application logs capture what your code does. Audit logs capture what identities do: specifically, what actions they took, on what resources, at what time. MCP audit logs are a specific case of the latter: they record which identity called which tool, with what arguments, when. The audience for application logs is your engineering team debugging a problem; the audience for audit logs is your security team, compliance reviewers, and incident responders.

Should I also log tool call results?

It depends on your use case and data sensitivity. Logging results (what the tool returned) gives you a more complete forensic picture; you can see not just what was requested but what data was actually retrieved. The tradeoff is that tool results often contain sensitive content directly: a snowflake_query result may return rows of customer data, a notion_get_page result may contain proprietary documents. If you log results, apply the same encryption treatment as arguments, be explicit about retention policy, and restrict access carefully. Many teams start by logging arguments only and add result logging later for specific high-risk servers where the full audit trail justifies the storage and data handling overhead.

Next steps

If you're managing how users authenticate to MCP servers: MCP authentication: bearer tokens, OAuth, shared vs. personal credentials, and token lifecycle

If you need agents to appear separately in the audit log: Agent identity and robot users: giving non-human principals their own identity so tool calls are attributed to the agent, not the engineer who deployed it

If you're in a regulated industry and need to understand retention requirements: Audit log retention: HIPAA retention requirements and how Aptible covers them

Aptible MCP Gateway gives engineering teams tool-level access control, audit logging, and centralized credential management for MCP without building the proxy infrastructure yourself. Deployed alongside Aptible LLM Gateway, it covers both LLM and tool call governance in one place.

>

text

Audit logging for MCP tool calls: what to record, how to store it, and how to query it

Tool calls are cross-system actions. When Claude calls notion_search or snowflake_query or github_create_pull_request, something happens outside your application boundary: data is retrieved, records are modified, external state changes. Standard application logs capture what your code does; they don't capture what your AI's tools do. That's a different audit surface, and it needs its own log.

This chapter covers what we record for every MCP tool call, how we store it, and how we structure it so it's queryable when you need it (which is usually under pressure, not in advance).

Solutions to consider

The most common starting point is server-side logging: relying on the MCP server itself to log tool calls. Some servers do this, most don't, and none of them do it consistently or in a format you control. You don't own the server's log pipeline, so you can't guarantee retention, format, or access.

Application-layer logging (logging tool calls from within your own application code) is more reliable but creates the same fragmentation problem as credentials: each service logs differently, attribution is scattered, and there's no single place to query across all tool usage. It also means every service has to implement logging correctly, which in practice means some don't.

Aptible’s solution: proxy-layer logging

The approach we took is proxy-layer logging: every tool call passes through the gateway, and the gateway writes the log record before proxying the call upstream. One enforcement point, one log format, one place to query. The tradeoff is that you need the gateway infrastructure, which is what the rest of this guide describes. If you already have that from Tool-level access control, the logging layer comes essentially for free.

What your MCP audit log should record

This is a general pattern that applies regardless of implementation stack. A useful MCP audit record needs to answer four questions after the fact: who made the call, what did they call, what did they pass to it, and when did it happen from where. That maps to a specific set of fields.

User identity. Not just a session token, a resolved identity that can be tied to a real person or agent. We store both the user ID (from the JWT or robot API key) and the email address for human users, since email is what security reviewers ask for. For robot users, the email field is empty and the user ID identifies the agent.

Tool name. The namespaced tool name: notion_search, not just search. Namespacing by server prevents ambiguity when multiple servers expose similarly named tools, and makes log queries straightforward without needing to join on the server record.

Server identity (two fields). We store both a foreign key to the server record and a denormalized snapshot of the server name. The FK supports joins and aggregation while the server exists; the snapshot survives if the server is deleted or renamed. An audit record that loses its server context when a server is removed is less useful than one that preserves it.

Arguments. What was actually passed to the tool. This is the most sensitive field in the record; a snowflake_query call contains the actual query, a notion_search call may contain proprietary terms. Arguments should be encrypted at rest.

Timestamp. Creation time of the log record, not the time the upstream call returned. If the upstream call fails after the log is written, you still have the record.

IP address. The client IP, resolved from X-Forwarded-For or X-Real-IP for deployments behind a load balancer or reverse proxy. Useful for anomaly detection and incident response.

User agent. Which client made the call: Claude Desktop, Claude Code, a custom agent, or a direct API call. Useful for filtering and for detecting calls made outside expected client paths.

Preserving server context: the snapshot pattern

This is a general pattern that applies to any audit log referencing a mutable resource. The server foreign key in the audit record is useful while the server exists: you can join against it to get the server's current metadata, aggregate usage by server, and cross-reference with the grants table. But server records get deleted; when a team removes an integration, or renames a server, the FK becomes null or points to the wrong name.

The snapshot field (server_name) is a denormalized string copy of the server name at the time the log record was written. It doesn't change when the server is deleted or renamed. For forensics (especially for records that need to be meaningful months or years later) the snapshot is the authoritative field, not the FK.

async def create_audit_log(
    *,
    claims: dict,
    org_id: str,
    tool_name: str,
    arguments: dict,
    user_agent: str,
    ip_address: str | None,
) -> None:
    try:
        server = McpServer.resolve(tool_name, org_id)
        McpAuditLog.objects.create(
            user_id=claims["id"],
            email=claims.get("email", ""),
            organization_id=org_id,
            tool_name=tool_name,
            arguments=arguments,          # encrypted by EncryptedJSONField
            user_agent=user_agent,
            ip_address=ip_address or None,
            server=server,                # FK, may become null if server is deleted
            server_name=server.name if server else "",  # snapshot, always preserved
        )
    except Exception:
        logger.exception(f"Failed to write audit log for tool={tool_name}")

Write the log before you proxy, not after

This ordering principle applies to any proxy-layer implementation. The audit log record is written before the tool call is proxied to the upstream server, not after.

If the upstream call fails after the log is written, the record still exists -- you know the call was attempted, with what arguments, by whom. If the log write fails, the proxy call doesn't proceed silently; the error surfaces. This asymmetry is intentional: it's better to have a log record for a call that didn't complete than to have a completed call with no record.

async def on_call_tool(self, context, call_next):
    auth = _request_auth.get()
    claims, org_id, allowed = auth

    # Enforcement: reject before logging or proxying
    if context.message.name not in allowed:
        return ToolResult(content=[TextContent(type="text", text="Tool not permitted")])

    # Write the log before proxying
    await create_audit_log(
        claims=claims,
        org_id=org_id,
        tool_name=context.message.name,
        arguments=getattr(context.message, "arguments", {}) or {},
        user_agent=get_http_headers(include={"user-agent"}).get("user-agent", ""),
        ip_address=_get_client_ip(),
    )

    # Then proxy to the upstream server
    server, config = await resolve_server_config(
        context.message.name, org_id, claims["id"]
    )
    async with Client(transport) as client:
        result = await client.call_tool(bare_tool, context.message.arguments or {})
        return ToolResult(content=result.content)

Structuring your audit logs for investigation

A log that exists but can't be searched is only marginally better than no log. The fields described above are the fields you'll filter on during an investigation. The examples below use Django ORM syntax; the filter logic translates directly to any query layer: SQL, Prisma, ActiveRecord, GORM, or otherwise.

The queries you'll actually run:

# What did this user call in the last 30 days?
McpAuditLog.objects.filter(
    organization_id=org_id,
    user_id=user_id,
    created_at__gte=timezone.now() - timedelta(days=30),
).order_by("-created_at")

# What has been called against the Snowflake server?
McpAuditLog.objects.filter(
    organization_id=org_id,
    server_name="snowflake",
).order_by("-created_at")

# Which users called this specific tool?
McpAuditLog.objects.filter(
    organization_id=org_id,
    tool_name="snowflake_query",
).values("email", "user_id").annotate(call_count=Count("id"))

# What happened in a specific time window?
McpAuditLog.objects.filter(
    organization_id=org_id,
    created_at__gte=incident_start,
    created_at__lte=incident_end,
).order_by("created_at")


Index on (organization_id, created_at) as a minimum. Add indexes on tool_name and server_name if you're running frequent analytical queries against those fields. For larger deployments, user_id is worth indexing separately.

The Day-Zero Normal CISO brief recommends establishing behavioral baselines for every autonomous agent with production access and alerting on deviation. The audit log is the data source for that: once you have a record of normal call patterns for a given agent role, deviations become detectable. That use case requires the log to be queryable, not just to exist.

How to handle retention

How long you keep audit logs depends on regulatory requirements and your own incident response posture. For teams in regulated industries, HIPAA requires audit log retention for six years from the date of creation or the date the record was last in effect. See Audit log retention for the full requirements.

For teams outside regulated industries, we recommend a minimum of 90 days for operational purposes (long enough to investigate most incidents) and at least one year for teams running agents with production write access. The argument for longer retention: the median time between a breach and its detection is still measured in weeks to months. A log that expired before you knew to look at it isn't useful.

The cost argument for longer retention is weak. Log storage at rest is cheap. The decision to keep less is almost always a configuration default, not a deliberate tradeoff.

FAQs

Do MCP servers log tool calls themselves?

Some do, most don't, and none do it in a format you control. Even for servers that log internally, you don't have access to those logs, can't guarantee their retention, and can't query across servers from a single place. Proxy-layer logging is the approach that gives you consistent coverage regardless of what individual servers do.

What if I don't have a proxy layer yet?

You can implement basic logging in your own application code as a starting point: log the tool name, timestamp, and user identity before calling the gateway. It won't give you arguments encryption or the snapshot pattern, but it's better than nothing. The proxy-layer approach described here is the more complete solution, and the other chapters in this guide describe how to build it.

Should arguments always be encrypted?

Yes, unless you have a specific reason not to. Arguments routinely contain query strings, search terms, and input data that may include sensitive content. The cost of encryption is low; the cost of a plaintext argument log being exposed is high. Encrypt by default and decrypt only for authorized review.

How do I handle audit log access for a security review?

Provide a read-only interface filtered to the organization and time range in scope. Don't give reviewers direct database access. Decrypted arguments should only be exposed through an authorized review workflow, not as a default in the query interface.

What's the difference between an audit log and application logs?

Application logs capture what your code does. Audit logs capture what identities do: specifically, what actions they took, on what resources, at what time. MCP audit logs are a specific case of the latter: they record which identity called which tool, with what arguments, when. The audience for application logs is your engineering team debugging a problem; the audience for audit logs is your security team, compliance reviewers, and incident responders.

Should I also log tool call results?

It depends on your use case and data sensitivity. Logging results (what the tool returned) gives you a more complete forensic picture; you can see not just what was requested but what data was actually retrieved. The tradeoff is that tool results often contain sensitive content directly: a snowflake_query result may return rows of customer data, a notion_get_page result may contain proprietary documents. If you log results, apply the same encryption treatment as arguments, be explicit about retention policy, and restrict access carefully. Many teams start by logging arguments only and add result logging later for specific high-risk servers where the full audit trail justifies the storage and data handling overhead.

Next steps

If you're managing how users authenticate to MCP servers: MCP authentication: bearer tokens, OAuth, shared vs. personal credentials, and token lifecycle

If you need agents to appear separately in the audit log: Agent identity and robot users: giving non-human principals their own identity so tool calls are attributed to the agent, not the engineer who deployed it

If you're in a regulated industry and need to understand retention requirements: Audit log retention: HIPAA retention requirements and how Aptible covers them

Aptible MCP Gateway gives engineering teams tool-level access control, audit logging, and centralized credential management for MCP without building the proxy infrastructure yourself. Deployed alongside Aptible LLM Gateway, it covers both LLM and tool call governance in one place.