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
- Complete Guide to Building ChatGPT Applications
- MCP Server Debugging and Troubleshooting for ChatGPT Apps
- OpenTelemetry Integration for ChatGPT Apps
- Error Tracking with Sentry for ChatGPT Apps
- ELK Stack Log Aggregation for ChatGPT Apps
- GDPR Compliance for ChatGPT Apps