MCP Server Logging Best Practices for ChatGPT Apps

Production-grade MCP (Model Context Protocol) servers require robust logging infrastructure to maintain reliability, debug issues, and ensure compliance. Unlike traditional web applications, MCP servers face unique observability challenges: they operate in ChatGPT's conversational context, handle real-time model interactions, and must maintain sub-500ms response times while generating actionable logs.

Poor logging practices lead to "black box" MCP servers where debugging requires guesswork, production incidents lack forensic data, and compliance audits fail due to missing audit trails. Conversely, excessive logging degrades performance, inflates storage costs, and creates noise that obscures critical signals.

This guide provides production-tested logging strategies for MCP servers, covering structured logging, log levels, aggregation pipelines, performance optimization, and security compliance. You'll learn how to implement logging that provides operational visibility without sacrificing the sub-second response times ChatGPT apps demand.

Whether you're building your first MCP server or scaling to millions of requests, these patterns ensure your logging infrastructure grows with your application while maintaining the observability needed for production success.

Structured Logging with Winston and Pino

Structured logging replaces ad-hoc console.log() statements with JSON-formatted logs that machines can parse, search, and analyze. For MCP servers handling thousands of concurrent ChatGPT conversations, structured logging is non-negotiable—it's the foundation of observability.

Winston: Production-Grade Structured Logger

Winston is the most mature Node.js logging library, offering transports, formatters, and error handling that production MCP servers require:

// src/logging/winston-logger.ts
import winston from 'winston';
import { Request, Response, NextFunction } from 'express';

/**
 * Winston Logger Configuration for MCP Server
 * Implements structured JSON logging with multiple transports
 * Production-ready with error handling and log rotation
 */

// Custom log format with timestamp, level, message, and metadata
const logFormat = winston.format.combine(
  winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
  winston.format.errors({ stack: true }), // Capture stack traces
  winston.format.metadata({
    fillExcept: ['timestamp', 'level', 'message']
  }),
  winston.format.json()
);

// Create logger instance
export const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: logFormat,
  defaultMeta: {
    service: 'mcp-server',
    version: process.env.APP_VERSION || '1.0.0',
    environment: process.env.NODE_ENV || 'development'
  },
  transports: [
    // Console transport for local development
    new winston.transports.Console({
      format: winston.format.combine(
        winston.format.colorize(),
        winston.format.printf(({ timestamp, level, message, metadata }) => {
          const meta = Object.keys(metadata).length
            ? JSON.stringify(metadata, null, 2)
            : '';
          return `${timestamp} [${level}]: ${message} ${meta}`;
        })
      )
    }),

    // File transport for persistent logs
    new winston.transports.File({
      filename: 'logs/error.log',
      level: 'error',
      maxsize: 10485760, // 10MB
      maxFiles: 5,
      tailable: true
    }),

    new winston.transports.File({
      filename: 'logs/combined.log',
      maxsize: 10485760, // 10MB
      maxFiles: 10,
      tailable: true
    })
  ],

  // Handle uncaught exceptions and rejections
  exceptionHandlers: [
    new winston.transports.File({ filename: 'logs/exceptions.log' })
  ],
  rejectionHandlers: [
    new winston.transports.File({ filename: 'logs/rejections.log' })
  ]
});

/**
 * Express middleware for request/response logging
 * Captures correlation IDs, latency, and request metadata
 */
export function requestLogger(req: Request, res: Response, next: NextFunction) {
  const startTime = Date.now();
  const correlationId = req.headers['x-correlation-id'] ||
                        `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

  // Attach correlation ID to request for downstream use
  req.correlationId = correlationId;

  // Log incoming request
  logger.info('Incoming request', {
    correlationId,
    method: req.method,
    path: req.path,
    query: req.query,
    userAgent: req.headers['user-agent'],
    ip: req.ip
  });

  // Capture response
  res.on('finish', () => {
    const duration = Date.now() - startTime;
    const logLevel = res.statusCode >= 500 ? 'error' :
                     res.statusCode >= 400 ? 'warn' : 'info';

    loggerlogLevel;
  });

  next();
}

// Type augmentation for Express Request
declare global {
  namespace Express {
    interface Request {
      correlationId?: string;
    }
  }
}

Pino: High-Performance Async Logger

For MCP servers requiring maximum throughput (10K+ requests/second), Pino offers 5-10x faster logging through async I/O and minimal serialization overhead:

// src/logging/pino-logger.ts
import pino from 'pino';
import { Request, Response, NextFunction } from 'express';

/**
 * Pino High-Performance Logger for MCP Server
 * Optimized for low-latency, high-throughput logging
 * Uses async transport to minimize performance impact
 */

// Create logger with async transport
export const logger = pino({
  level: process.env.LOG_LEVEL || 'info',

  // Base metadata included in every log
  base: {
    service: 'mcp-server',
    version: process.env.APP_VERSION || '1.0.0',
    environment: process.env.NODE_ENV || 'development',
    pid: process.pid,
    hostname: process.env.HOSTNAME
  },

  // Timestamp using high-resolution timer
  timestamp: pino.stdTimeFunctions.isoTime,

  // Serializers for common objects
  serializers: {
    req: pino.stdSerializers.req,
    res: pino.stdSerializers.res,
    err: pino.stdSerializers.err
  },

  // Format configuration
  formatters: {
    level: (label) => {
      return { level: label };
    },
    bindings: (bindings) => {
      return {
        pid: bindings.pid,
        hostname: bindings.hostname
      };
    }
  },

  // Async transport to file (non-blocking)
  transport: process.env.NODE_ENV === 'production' ? {
    targets: [
      {
        target: 'pino/file',
        options: {
          destination: '/var/log/mcp-server/app.log',
          mkdir: true
        }
      },
      {
        target: 'pino-pretty',
        level: 'info',
        options: {
          destination: 1, // stdout
          colorize: true,
          translateTime: 'SYS:standard',
          ignore: 'pid,hostname'
        }
      }
    ]
  } : {
    target: 'pino-pretty',
    options: {
      colorize: true,
      translateTime: 'SYS:standard',
      ignore: 'pid,hostname'
    }
  }
});

/**
 * Pino-optimized request logger middleware
 * Minimal overhead using Pino's serializers
 */
export function pinoRequestLogger(req: Request, res: Response, next: NextFunction) {
  const startTime = Date.now();
  const correlationId = req.headers['x-correlation-id'] ||
                        `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

  req.correlationId = correlationId;

  // Child logger with correlation ID (prevents repetition)
  const childLogger = logger.child({ correlationId });

  childLogger.info({ req }, 'Incoming request');

  res.on('finish', () => {
    const duration = Date.now() - startTime;

    childLogger.info({
      res,
      duration,
      statusCode: res.statusCode,
      performanceIssue: duration > 500
    }, 'Request completed');
  });

  next();
}

Correlation IDs: Tracing Requests Across Services

MCP servers often interact with multiple backend services—databases, external APIs, cache layers. Without correlation IDs, tracking a single ChatGPT conversation across distributed services becomes impossible.

// src/middleware/correlation-id.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';

/**
 * Correlation ID Middleware for Distributed Tracing
 * Generates or extracts correlation IDs for request tracking
 * Propagates IDs to downstream services via headers
 */

export interface CorrelationContext {
  correlationId: string;
  conversationId?: string;
  userId?: string;
  sessionId?: string;
}

/**
 * Generate correlation ID from request headers or create new
 */
export function correlationIdMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
) {
  // Extract or generate correlation ID
  const correlationId =
    req.headers['x-correlation-id'] as string ||
    req.headers['x-request-id'] as string ||
    uuidv4();

  // Extract ChatGPT conversation context (if available)
  const conversationId = req.headers['x-conversation-id'] as string;
  const userId = req.headers['x-user-id'] as string;
  const sessionId = req.headers['x-session-id'] as string;

  // Store in request for downstream use
  req.correlationContext = {
    correlationId,
    conversationId,
    userId,
    sessionId
  };

  // Propagate in response headers for client-side tracing
  res.setHeader('x-correlation-id', correlationId);

  next();
}

/**
 * Axios interceptor to propagate correlation IDs to downstream services
 */
export function createAxiosWithCorrelation(
  correlationContext: CorrelationContext
) {
  const axios = require('axios').create();

  // Request interceptor: add correlation headers
  axios.interceptors.request.use((config: any) => {
    config.headers['x-correlation-id'] = correlationContext.correlationId;

    if (correlationContext.conversationId) {
      config.headers['x-conversation-id'] = correlationContext.conversationId;
    }

    if (correlationContext.userId) {
      config.headers['x-user-id'] = correlationContext.userId;
    }

    return config;
  });

  return axios;
}

// Type augmentation for Express Request
declare global {
  namespace Express {
    interface Request {
      correlationContext?: CorrelationContext;
    }
  }
}

Log Levels and Categories: Signal vs. Noise

Production MCP servers generate thousands of log entries per second. Without disciplined log level usage, critical errors drown in debug noise, and log storage costs spiral.

Standard Log Levels

// DEBUG: Development-only details
logger.debug('Parsing tool request', { toolName, inputSchema });

// INFO: Normal operational events (default production level)
logger.info('Tool execution completed', { toolName, duration: 234 });

// WARN: Recoverable errors or degraded performance
logger.warn('External API slow response', { service: 'weather-api', duration: 3400 });

// ERROR: Failures requiring investigation
logger.error('Database connection failed', { error, retryAttempt: 3 });

// FATAL: Critical failures requiring immediate action
logger.fatal('MCP server unable to start', { error, port: 3000 });

Log Level Guidelines

  • DEBUG: Never in production (disable via LOG_LEVEL=info). Use for development debugging only.
  • INFO: Default production level. Log business events (tool calls, authentications, completions).
  • WARN: Degraded performance (slow queries >1s, rate limit warnings, retries).
  • ERROR: Recoverable failures (external API errors, validation failures).
  • FATAL: Unrecoverable failures (server startup failures, critical dependency outages).

Category-Based Logging

// Create child loggers for different subsystems
const authLogger = logger.child({ category: 'auth' });
const toolLogger = logger.child({ category: 'tool-execution' });
const dbLogger = logger.child({ category: 'database' });

// Usage
authLogger.info('OAuth token refreshed', { userId, provider: 'google' });
toolLogger.warn('Tool execution timeout', { toolName, timeout: 5000 });
dbLogger.error('Query failed', { query, error });

Log Aggregation with ELK Stack

Structured JSON logs are only useful if you can search, analyze, and visualize them. The ELK Stack (Elasticsearch, Logstash, Kibana) is the industry standard for log aggregation.

Docker Compose ELK Stack

# docker-compose.elk.yml
version: '3.8'

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - "ES_JAVA_OPTS=-Xms1g -Xmx1g"
      - xpack.security.enabled=false
    ports:
      - "9200:9200"
      - "9300:9300"
    volumes:
      - elasticsearch-data:/usr/share/elasticsearch/data
    networks:
      - elk

  logstash:
    image: docker.elastic.co/logstash/logstash:8.11.0
    container_name: logstash
    volumes:
      - ./logstash/pipeline:/usr/share/logstash/pipeline
      - ./logs:/var/log/mcp-server:ro
    ports:
      - "5044:5044"
      - "9600:9600"
    environment:
      - "LS_JAVA_OPTS=-Xms512m -Xmx512m"
    depends_on:
      - elasticsearch
    networks:
      - elk

  kibana:
    image: docker.elastic.co/kibana/kibana:8.11.0
    container_name: kibana
    ports:
      - "5601:5601"
    environment:
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200
    depends_on:
      - elasticsearch
    networks:
      - elk

  # Filebeat to ship logs from MCP server to Logstash
  filebeat:
    image: docker.elastic.co/beats/filebeat:8.11.0
    container_name: filebeat
    user: root
    volumes:
      - ./filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
      - ./logs:/var/log/mcp-server:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    depends_on:
      - logstash
    networks:
      - elk

volumes:
  elasticsearch-data:
    driver: local

networks:
  elk:
    driver: bridge

Logstash Pipeline Configuration

# logstash/pipeline/mcp-server.conf
input {
  beats {
    port => 5044
  }
}

filter {
  # Parse JSON logs
  json {
    source => "message"
  }

  # Extract timestamp
  date {
    match => [ "timestamp", "ISO8601", "yyyy-MM-dd HH:mm:ss.SSS" ]
    target => "@timestamp"
  }

  # Add geolocation for IP addresses
  geoip {
    source => "ip"
    target => "geoip"
  }

  # Extract error severity
  if [level] == "error" or [level] == "fatal" {
    mutate {
      add_tag => [ "error" ]
    }
  }

  # Flag performance issues
  if [duration] {
    ruby {
      code => "
        duration = event.get('duration')
        if duration.to_i > 1000
          event.set('performance_issue', 'slow')
        end
      "
    }
  }
}

output {
  elasticsearch {
    hosts => ["elasticsearch:9200"]
    index => "mcp-server-logs-%{+YYYY.MM.dd}"
  }

  # Optionally output to stdout for debugging
  stdout {
    codec => rubydebug
  }
}

CloudWatch Logs Integration for AWS

For MCP servers deployed on AWS (Lambda, ECS, EC2), CloudWatch Logs provides native integration with AWS infrastructure.

// src/logging/cloudwatch-logger.ts
import winston from 'winston';
import WinstonCloudWatch from 'winston-cloudwatch';

/**
 * CloudWatch Logs Integration for MCP Server
 * Ships logs to AWS CloudWatch with automatic log group creation
 * Includes IAM permissions and log retention configuration
 */

const cloudwatchConfig = {
  logGroupName: `/mcp-server/${process.env.NODE_ENV}`,
  logStreamName: `${process.env.HOSTNAME}-${Date.now()}`,
  awsRegion: process.env.AWS_REGION || 'us-east-1',
  jsonMessage: true,

  // Batch logs to reduce API calls
  uploadRate: 2000, // Upload every 2 seconds

  // Retry configuration
  errorHandler: (err: Error) => {
    console.error('CloudWatch logging error:', err);
  }
};

// Create logger with CloudWatch transport
export const cloudwatchLogger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.json()
  ),
  defaultMeta: {
    service: 'mcp-server',
    version: process.env.APP_VERSION || '1.0.0',
    environment: process.env.NODE_ENV
  },
  transports: [
    new winston.transports.Console(),
    new WinstonCloudWatch(cloudwatchConfig)
  ]
});

/**
 * CloudWatch log insights query examples
 * Run these in CloudWatch Logs Insights console
 */

// Find all errors in last hour
const errorQuery = `
fields @timestamp, level, message, metadata.error
| filter level = "error"
| sort @timestamp desc
| limit 100
`;

// Find slow requests (>500ms)
const slowRequestQuery = `
fields @timestamp, metadata.path, metadata.duration, metadata.correlationId
| filter metadata.duration > 500
| sort metadata.duration desc
| limit 50
`;

// Count requests by status code
const statusCodeQuery = `
fields @timestamp, metadata.statusCode
| filter metadata.statusCode >= 400
| stats count() by metadata.statusCode
| sort count desc
`;

Performance-Optimized Logging

Logging adds latency to every request. For MCP servers targeting sub-500ms response times, logging overhead must be minimized through async I/O, sampling, and buffering.

Async Logging Implementation

// src/logging/async-logger.ts
import { AsyncLocalStorage } from 'async_hooks';
import { EventEmitter } from 'events';

/**
 * Async Logger with Buffering and Sampling
 * Reduces logging overhead through batching and selective sampling
 * Maintains <5ms logging overhead for 99th percentile
 */

interface LogEntry {
  timestamp: string;
  level: string;
  message: string;
  metadata: Record<string, any>;
}

class AsyncLogger extends EventEmitter {
  private buffer: LogEntry[] = [];
  private bufferSize: number = 100;
  private flushInterval: number = 5000; // 5 seconds
  private sampleRate: number = 1.0; // 100% (reduce for high-traffic scenarios)
  private asyncStorage: AsyncLocalStorage<Map<string, any>>;

  constructor() {
    super();
    this.asyncStorage = new AsyncLocalStorage();

    // Flush buffer periodically
    setInterval(() => this.flush(), this.flushInterval);

    // Flush on process exit
    process.on('beforeExit', () => this.flush());
  }

  /**
   * Log with sampling (probabilistic logging)
   */
  log(level: string, message: string, metadata: Record<string, any> = {}) {
    // Sample based on configured rate
    if (Math.random() > this.sampleRate && level !== 'error' && level !== 'fatal') {
      return; // Skip this log entry
    }

    const entry: LogEntry = {
      timestamp: new Date().toISOString(),
      level,
      message,
      metadata: {
        ...metadata,
        ...this.getAsyncContext()
      }
    };

    this.buffer.push(entry);

    // Flush if buffer is full
    if (this.buffer.length >= this.bufferSize) {
      this.flush();
    }
  }

  /**
   * Get context from async local storage (correlation IDs, etc.)
   */
  private getAsyncContext(): Record<string, any> {
    const store = this.asyncStorage.getStore();
    return store ? Object.fromEntries(store) : {};
  }

  /**
   * Flush buffered logs to transport
   */
  private async flush() {
    if (this.buffer.length === 0) return;

    const entriesToFlush = [...this.buffer];
    this.buffer = [];

    // Emit batch for transport (e.g., write to file, send to CloudWatch)
    this.emit('flush', entriesToFlush);

    // In production, send to log aggregation service
    // await this.sendToLogService(entriesToFlush);
  }

  /**
   * Set sample rate dynamically (useful for load shedding)
   */
  setSampleRate(rate: number) {
    if (rate < 0 || rate > 1) {
      throw new Error('Sample rate must be between 0 and 1');
    }
    this.sampleRate = rate;
  }

  /**
   * Run code with async context (correlation IDs, user IDs)
   */
  runWithContext<T>(context: Record<string, any>, fn: () => T): T {
    const store = new Map(Object.entries(context));
    return this.asyncStorage.run(store, fn);
  }
}

export const asyncLogger = new AsyncLogger();

// Usage example
asyncLogger.runWithContext({ correlationId: 'abc-123', userId: 'user-456' }, () => {
  asyncLogger.log('info', 'Processing tool request', { toolName: 'weather' });
});

Security and Compliance: PII Redaction

MCP servers handling user data must redact Personally Identifiable Information (PII) from logs to comply with GDPR, HIPAA, and other privacy regulations.

// src/logging/pii-redaction.ts
/**
 * PII Redaction Filter for Structured Logs
 * Automatically redacts sensitive data (emails, SSNs, credit cards)
 * Compliant with GDPR, HIPAA, PCI-DSS
 */

interface RedactionRule {
  pattern: RegExp;
  replacement: string;
}

const PII_PATTERNS: RedactionRule[] = [
  // Email addresses
  {
    pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
    replacement: '[EMAIL_REDACTED]'
  },

  // US Social Security Numbers
  {
    pattern: /\b\d{3}-\d{2}-\d{4}\b/g,
    replacement: '[SSN_REDACTED]'
  },

  // Credit card numbers (various formats)
  {
    pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b/g,
    replacement: '[CC_REDACTED]'
  },

  // US Phone numbers
  {
    pattern: /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g,
    replacement: '[PHONE_REDACTED]'
  },

  // IPv4 addresses (optional - may be needed for security)
  {
    pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
    replacement: '[IP_REDACTED]'
  },

  // API keys and tokens (generic pattern)
  {
    pattern: /\b[A-Za-z0-9]{32,}\b/g,
    replacement: '[TOKEN_REDACTED]'
  }
];

/**
 * Recursively redact PII from log metadata
 */
export function redactPII(obj: any): any {
  if (typeof obj === 'string') {
    return redactString(obj);
  }

  if (Array.isArray(obj)) {
    return obj.map(item => redactPII(item));
  }

  if (obj !== null && typeof obj === 'object') {
    const redacted: Record<string, any> = {};

    for (const [key, value] of Object.entries(obj)) {
      // Redact sensitive keys entirely
      if (isSensitiveKey(key)) {
        redacted[key] = '[REDACTED]';
      } else {
        redacted[key] = redactPII(value);
      }
    }

    return redacted;
  }

  return obj;
}

/**
 * Redact PII from string using regex patterns
 */
function redactString(str: string): string {
  let redacted = str;

  for (const rule of PII_PATTERNS) {
    redacted = redacted.replace(rule.pattern, rule.replacement);
  }

  return redacted;
}

/**
 * Check if key name indicates sensitive data
 */
function isSensitiveKey(key: string): boolean {
  const sensitiveKeys = [
    'password', 'passwd', 'pwd',
    'secret', 'api_key', 'apiKey', 'token',
    'authorization', 'auth',
    'credit_card', 'creditCard', 'ssn',
    'private_key', 'privateKey'
  ];

  const lowerKey = key.toLowerCase();
  return sensitiveKeys.some(sensitive => lowerKey.includes(sensitive));
}

/**
 * Winston format for PII redaction
 */
import winston from 'winston';

export const piiRedactionFormat = winston.format((info) => {
  return {
    ...info,
    message: redactString(info.message),
    metadata: redactPII(info.metadata || {})
  };
})();

Implementing Your MCP Server Logging Strategy

Production-grade logging transforms MCP servers from "black boxes" to observable, debuggable systems. By implementing structured logging with Winston or Pino, correlation IDs for distributed tracing, disciplined log levels, ELK Stack or CloudWatch aggregation, async buffering for performance, and PII redaction for compliance, you build logging infrastructure that scales from prototype to millions of ChatGPT conversations.

Start with Winston's structured logging and correlation ID middleware—this provides 80% of the observability benefits with minimal complexity. As traffic grows, add async buffering and log sampling to maintain sub-500ms response times. Finally, integrate ELK Stack or CloudWatch Logs to centralize log search and analysis across distributed MCP servers.

Ready to build production-grade ChatGPT apps with enterprise logging? MakeAIHQ provides the complete MCP server development platform with built-in logging, monitoring, and observability—no DevOps expertise required. From prototype to production in 48 hours. Start your free trial today.


Related Resources

External References