Topic: mcp server audit logging

MCP server audit logging — what to log, what to redact, and how to route it

Audit logging in an MCP server has a constraint that doesn't exist in conventional API servers: stdout is captured by the agent. Anything written to stdout on a stdio-transport server becomes part of the MCP protocol stream — it may be echoed back to the LLM, appear in the agent's conversation context, or trigger downstream tool calls. A console.log(`Searching with query: ${query}`) in a tool handler is not just a noisy log line; it is a potential credential echo and a prompt-injection surface. Getting logging right requires understanding this constraint before picking a log format.

What to log

Every MCP tool invocation should produce one structured log event with these fields:

{
  "ts": "2026-05-31T14:22:01.432Z",    // ISO 8601, millisecond precision
  "level": "info",
  "event": "tool_call",
  "tool": "search",                    // tool name — safe to log
  "arg_keys": ["query", "limit"],      // argument NAMES only, never values
  "caller": "claude-code/3.2.1",       // agent user agent if available
  "duration_ms": 234,
  "status": "ok",                      // ok | error | timeout | rate_limited
  "error_code": null                   // error type if status != ok
}

The arg_keys field is the critical design choice. Log argument names, not values. Argument values may contain user PII, injected instructions, or the value that triggered an SSRF — none of which should be in your log store where it can become a secondary exfiltration surface. The argument names alone are sufficient for forensic "which tool, with what parameters shape" analysis.

What not to log

Layer 1 — structured logger with redaction

import pino from 'pino';

// All log output goes to stderr — never stdout on stdio-transport servers
const log = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  redact: {
    paths: [
      'token', 'apiKey', 'api_key', 'secret', 'password', 'authorization',
      'credentials', 'access_token', 'refresh_token',
      '*.token', '*.api_key', '*.secret'
    ],
    remove: true   // remove the field entirely rather than replacing with [REDACTED]
  }
}, pino.destination(2));   // fd 2 = stderr

export function logToolCall(tool: string, argKeys: string[], startMs: number, status: string, errorCode?: string) {
  log.info({
    event: 'tool_call',
    tool,
    arg_keys: argKeys,
    duration_ms: Date.now() - startMs,
    status,
    error_code: errorCode ?? null
  });
}

pino's redact option is path-based — it removes matching fields before serialization, so the value never enters the string encoding step. Test it by passing a known-pattern token as an argument in your integration tests and asserting it does not appear in the parsed log output.

Layer 2 — tool handler instrumentation

// Wrap all tool handlers with the same instrumentation decorator
function withAuditLog<T extends Record<string, unknown>>(
  toolName: string,
  handler: (args: T) => Promise<CallToolResult>
) {
  return async (args: T): Promise<CallToolResult> => {
    const start = Date.now();
    try {
      const result = await handler(args);
      logToolCall(toolName, Object.keys(args), start, 'ok');
      return result;
    } catch (err) {
      const code = err instanceof McpError ? err.code.toString() : 'internal_error';
      logToolCall(toolName, Object.keys(args), start, 'error', code);
      // Re-throw as a clean MCP error — never expose raw error to agent
      throw new McpError(ErrorCode.InternalError, `Tool ${toolName} failed.`);
    }
  };
}

// Usage:
server.tool('search', withAuditLog('search', async ({ query, limit }) => {
  // handler logic
}));

Layer 3 — stderr log routing in production

On stdio-transport, the MCP host (Claude Code, Cursor, Windsurf) typically captures stderr separately from the JSON-RPC stdout stream. But "captured separately" doesn't mean "shipped to your SIEM" — most agent clients discard stderr or surface it only as debug output visible to the developer. For production audit trail, you need to route stderr explicitly:

Layer 4 — SIEM integration and alerting

The structured log events from Layer 1 are designed to be queryable. Useful SIEM rules for MCP audit logs:

Run a SkillAudit to check your logging posture

The SkillAudit engine's credentials axis includes a check for process.env and os.environ reads that flow into tool handler return values or log statements — the stdout-capture vector. The security axis checks for unhandled exception handlers that write to stdout. Paste your GitHub URL at skillaudit.dev. Results in 60 seconds.