MCP Security · Observability
MCP server structured logging security: log injection and security event schema
Your MCP server's audit log is only as trustworthy as the way it serializes user-controlled data. An attacker who can inject newlines, braces, or quotes into a log field can spoof entire log entries — adding fake success events, masking real errors, or poisoning the audit trail that security teams rely on during incident response. Structured JSON logging prevents injection; a complete security event schema gives reviewers and SkillAudit something meaningful to verify.
How log injection works in MCP servers
Log injection is possible whenever user-controlled data is concatenated into a log line rather than being serialized as a field value. In an MCP server, the attack surface is wider than typical web apps because tool arguments come from the LLM, and the LLM can be prompted to supply adversarial inputs:
// VULNERABLE: string concatenation logging
console.log(`[AUDIT] tool=read_file path=${args.path} caller=${ctx.callerId}`);
// If args.path = "../etc/passwd\n[AUDIT] tool=read_file path=.env caller=admin"
// Log output:
// [AUDIT] tool=read_file path=../etc/passwd
// [AUDIT] tool=read_file path=.env caller=admin
// The second line is fake — but a SIEM parsing this file can't tell.
The attacker's goal: insert a fake log entry that makes an attack look authorized, or delete the real entry by injecting a carriage return that overwrites the previous line in some log parsers.
Why JSON (NDJSON) logging prevents injection
JSON serialization encodes all special characters. A newline in a field value becomes \n, a closing brace becomes \}, and the JSON parser on the receiving end sees the original value, not the attacker's injected structure:
// SECURE: structured JSON (using pino, winston, or similar)
import pino from 'pino';
const logger = pino({ level: 'info' });
// Same malicious input — safe in structured JSON
logger.info({
event: 'tool_call',
tool: 'read_file',
path: args.path, // "../etc/passwd\n[AUDIT] tool=read_file path=.env caller=admin"
callerId: ctx.callerId,
});
// Output (one line of NDJSON):
// {"level":30,"time":1718290812345,"event":"tool_call","tool":"read_file","path":"../etc/passwd\\n[AUDIT] tool=read_file path=.env caller=admin","callerId":"user_123"}
// The injected text is encoded — it cannot create a second log entry.
Security event schema for MCP servers
A complete MCP server audit trail needs to capture enough context for incident response without logging PII or credential values. SkillAudit's audit trail analysis looks for these event types and fields:
| Event type | Required fields | Purpose |
|---|---|---|
tool_call |
tool, callerId, sessionId, argsHash, durationMs, success | Every tool invocation — argsHash identifies the call without logging raw args that may contain PII |
auth_failure |
tool, callerId, reason, failureType (missing_token|expired|insufficient_scope|idor) | Authentication and authorization rejections — critical for detecting credential stuffing and IDOR probing |
rate_limit_hit |
tool, callerId, windowMs, limit, current | Rate limit enforcement — patterns indicate enumeration or brute-force |
validation_rejection |
tool, field, reason, valueLength | Input validation failures — high volume on specific fields indicates fuzzing |
chain_guard_block |
blockedTool, sessionCategories, sessionCallCount, reason | Tool chaining category guard blocks — indicates attempted read-to-write escalation |
session_start |
sessionId, callerId, scopes, clientIp (hashed), userAgent | Session establishment — baseline for correlating subsequent events |
Avoiding credential leakage in logs
The second major logging security issue: logging tool arguments that contain secrets. MCP servers that log raw arguments may inadvertently log API keys passed as args or credentials extracted from environment variables:
const SENSITIVE_ARG_FIELDS = ['api_key', 'token', 'password', 'secret', 'authorization', 'credential']; function sanitizeArgs(args: Record): Record { const sanitized: Record = {}; for (const [key, value] of Object.entries(args)) { if (SENSITIVE_ARG_FIELDS.some(s => key.toLowerCase().includes(s))) { sanitized[key] = '[REDACTED]'; } else if (typeof value === 'string' && value.length > 500) { sanitized[key] = `[truncated:${value.length}]`; // don't log large blobs } else { sanitized[key] = value; } } return sanitized; } logger.info({ event: 'tool_call', tool: toolName, args: sanitizeArgs(args), // never log raw args directly argsHash: argsHash(args), // hash for correlation without exposure callerId: ctx.session.callerId, sessionId: ctx.session.id, });
Log forwarding security
Security logs that stay on the application server are lost during a compromise. Forward to an append-only external sink:
// pino-loki / pino-cloudwatch / custom transport
const transport = pino.transport({
targets: [
{ target: 'pino/file', options: { destination: '/var/log/mcp-server/audit.ndjson' } },
{ target: 'pino-loki', options: { host: process.env.LOKI_URL, labels: { app: 'mcp-server' } } },
],
});
const logger = pino({ level: 'info' }, transport);
SkillAudit checks for evidence of structured logging (pino, winston, bunyan, or equivalent), a security-event schema with at minimum auth_failure and tool_call events, and argument sanitization patterns. Servers with only console.log calls receive a lower audit trail score.
SkillAudit findings for logging security
Run a SkillAudit to see your server's audit trail score. Static analysis detects console.log with template literal interpolation of arg fields, logger.info(rawArgs) without sanitization, and missing auth failure event handlers. Paste your GitHub URL →
Related: MCP server observability and security logging · tool chaining attack security · audit trail for SOC 2 and GDPR