MCP server logging level elevation security
The MCP 2025-03-26 specification defines logging/setLevel as a client-callable method that changes the server’s log verbosity. Servers emit notifications/message log entries to connected clients. When a client sets the level to DEBUG, the server begins emitting verbose diagnostic logs that typically include full request parameters, database queries, authentication headers, and environment variable values. Four SA-MCP-LOG patterns document the attack surface: DEBUG level credential leakage, cross-session log broadcast, log flood DoS, and unauthenticated level elevation.
The MCP logging system mirrors syslog severity levels: debug, info, notice, warning, error, critical, alert, emergency. Servers set a default level and only emit notifications for messages at or above that level. logging/setLevel allows the client to lower this threshold. The critical property of DEBUG logging in MCP is that the log content is transmitted as a JSON-RPC notification over the same transport channel as tool results and resource content — it is not a separate log stream and is not subject to different access controls.
SA-MCP-LOG-001: DEBUG level triggers credential and PII leakage in log notifications
MCP servers that log full request parameters, database query strings, HTTP headers, or environment values at DEBUG level will transmit these sensitive values as notifications/message when a client calls logging/setLevel with debug. This is not a hypothetical: the Node.js MCP SDK’s built-in debug logging includes full JSON-RPC message bodies, which contain all tool arguments. If a tool’s arguments include API keys, passwords, or PII, those values appear verbatim in DEBUG log notifications.
// VULNERABLE: server logs full request body at DEBUG level
server.setRequestHandler('tools/call', async (req, context) => {
logger.debug('Tool call received', { request: req });
// req includes: { method, params: { name, arguments: { api_key: 'sk-...', password: '...', email: '...' } } }
// At DEBUG level: full arguments object sent as notification to client
// Also common: logging HTTP upstream requests with auth headers
logger.debug('Upstream API call', {
url: upstreamUrl,
headers: { Authorization: `Bearer ${process.env.UPSTREAM_API_KEY}` } // key leaked
});
});
// ATTACK: client calls logging/setLevel to extract credentials
await client.request({
method: 'logging/setLevel',
params: { level: 'debug' }
});
// Server now emits notifications/message for every DEBUG log entry
// Client receives full request bodies including other users' tool arguments (multi-tenant)
// Client receives backend API keys from upstream request logs
// SECURE: sanitize log data before emitting at any level
function sanitizeForLog(data) {
if (typeof data !== 'object' || data === null) return data;
const SENSITIVE_KEYS = /api[_-]?key|password|secret|token|auth|credential|bearer/i;
return Object.fromEntries(
Object.entries(data).map(([k, v]) => [
k,
SENSITIVE_KEYS.test(k) ? '[REDACTED]' :
(typeof v === 'object' ? sanitizeForLog(v) : v)
])
);
}
server.setRequestHandler('tools/call', async (req, context) => {
logger.debug('Tool call received', {
name: req.params.name,
argKeys: Object.keys(req.params.arguments ?? {}), // log keys, not values
sessionId: context.sessionId.slice(0, 8) // partial ID only
});
});
// DETECTION (SkillAudit):
// Find logger.debug() / console.debug() calls that include full request objects
const debugLogCalls = findDebugLogCalls();
for (const call of debugLogCalls) {
if (includesFullRequestOrArgs(call)) → CRITICAL // SA-MCP-LOG-001
if (includesAuthHeaders(call)) → CRITICAL // SA-MCP-LOG-001
if (includesEnvVars(call)) → CRITICAL // SA-MCP-LOG-001
}
DEBUG credential leakage is the most severe MCP logging vulnerability: At INFO level, logs are typically safe to expose to authenticated clients. At DEBUG level, the same log pipeline transmits internal system state that was never intended to be client-visible. A tool that takes { api_key: "sk-prod-xxxxx" } as an argument will expose that key in the log notification stream the moment a client elevates to DEBUG. The key appears in the client’s notification handler, in the transport layer, and in any client-side log persistence.
logging/setLevel to debug causes credential and PII leakage via notifications/message. Detection: logger.debug calls with full request/auth objects → CRITICAL.SA-MCP-LOG-002: log notifications broadcast to all sessions in multi-tenant server
MCP servers that send notifications/message log entries to all connected sessions (rather than routing only to the session that called logging/setLevel) allow any elevated-verbosity client to receive log messages from other sessions’ operations. In a multi-user hosted MCP server, one user setting DEBUG level causes all users’ tool call parameters to appear in that user’s log notification stream — a complete cross-user data leak via the logging channel.
// VULNERABLE: log notifications sent to all connected sessions
const sessions = new Map(); // sessionId → transport
let globalLogLevel = 'info';
server.setRequestHandler('logging/setLevel', async (req, context) => {
globalLogLevel = req.params.level; // ← changes level for the entire process
return {};
});
function emitLog(level, message, data) {
if (logLevelValue(level) < logLevelValue(globalLogLevel)) return;
// Send to ALL sessions — not just the one that requested elevated verbosity
for (const [sessionId, transport] of sessions) {
transport.send({
jsonrpc: '2.0',
method: 'notifications/message',
params: { level, logger: 'server', data: { message, ...data } }
});
// Every session receives log entries for every other session's operations
}
}
// ATTACK: attacker session sets DEBUG; receives all other sessions' log data
// Session A (attacker): logging/setLevel → 'debug'
// Session B (victim): calls tools/call with { password: 'hunter2' }
// Server DEBUG logs tool call → emitLog sends to ALL sessions including attacker
// Session A receives: { message: 'Tool call', password: 'hunter2' }
// SECURE: scope log level and delivery to the requesting session only
const sessionLogLevels = new Map(); // sessionId → level
server.setRequestHandler('logging/setLevel', async (req, context) => {
sessionLogLevels.set(context.sessionId, req.params.level); // per-session
return {};
});
function emitLog(level, message, data, originatingSessionId) {
// Only send to the session whose log level permits this message
// AND only if that session is the one that generated this log event
const sessionId = originatingSessionId;
const sessionLevel = sessionLogLevels.get(sessionId) ?? 'info';
if (logLevelValue(level) < logLevelValue(sessionLevel)) return;
const transport = sessions.get(sessionId);
transport?.send({
jsonrpc: '2.0',
method: 'notifications/message',
params: { level, logger: 'server', data: { message, ...data } }
});
}
// DETECTION (SkillAudit):
// Check logging/setLevel handler: does it set a global or per-session level?
const setLevelHandler = findRequestHandler('logging/setLevel');
if (setsGlobalLogLevel(setLevelHandler)) → CRITICAL // SA-MCP-LOG-002
// Check log emission: does it iterate all sessions or target only originating session?
const logEmitter = findLogNotificationEmitter();
if (iteratesAllSessions(logEmitter)) → CRITICAL // SA-MCP-LOG-002
Global log level + all-sessions broadcast = cross-user wiretap: This is the most common logging implementation mistake in community MCP servers. Developers test with a single client, global log level works fine. In production with multiple concurrent users, the first user to call logging/setLevel('debug') receives a real-time stream of every other user’s tool call parameters, resource content, and authentication data — a complete cross-user data leak accessible without any special privileges.
logging/setLevel handler + all-sessions iteration in log emitter → CRITICAL.SA-MCP-LOG-003: DEBUG log flood causes notification volume DoS
MCP servers with verbose DEBUG instrumentation generate one or more log entries per operation step. At INFO level, a single tool call might produce 2-3 log notifications. At DEBUG level, the same tool call might produce 50-200 log notifications (each database query, each HTTP subrequest, each cache lookup, each intermediate state). An attacker who sets DEBUG level and then triggers rapid tool calls creates a notification flood that can saturate the WebSocket/SSE transport, exhaust server memory for notification queues, and degrade service for all connected clients.
// VULNERABLE: no rate limit on log notifications; DEBUG level per-call volume
server.setRequestHandler('tools/call', async (req, context) => {
logger.debug('Starting tool call', { name: req.params.name });
const items = await db.query('SELECT * FROM large_table'); // returns 10,000 rows
logger.debug('DB query complete', { rowCount: items.length });
for (const item of items) {
logger.debug('Processing item', { id: item.id, data: item }); // 10,000 debug entries
await processItem(item);
logger.debug('Item processed', { id: item.id }); // 10,000 more
}
// Single tool call at DEBUG level = 20,002 notifications
// 50 concurrent tool calls = 1,000,100 notifications
// Each notification is a JSON-RPC message sent over the transport
});
// ATTACK:
await client.request({ method: 'logging/setLevel', params: { level: 'debug' } });
// Now flood with tool calls:
for (let i = 0; i < 100; i++) {
client.request({ method: 'tools/call', params: { name: 'process_data', arguments: {} } });
}
// Server generates millions of debug notifications; transport queue exhausted
// SECURE: rate limit log notifications; cap DEBUG verbosity
const LOG_RATE_LIMIT = 100; // max 100 log notifications per second per session
const logTokenBuckets = new Map(); // sessionId → { tokens, lastRefill }
function canEmitLog(sessionId) {
const now = Date.now();
const bucket = logTokenBuckets.get(sessionId) ?? { tokens: LOG_RATE_LIMIT, lastRefill: now };
const elapsed = (now - bucket.lastRefill) / 1000;
bucket.tokens = Math.min(LOG_RATE_LIMIT, bucket.tokens + elapsed * LOG_RATE_LIMIT);
bucket.lastRefill = now;
logTokenBuckets.set(sessionId, bucket);
if (bucket.tokens >= 1) { bucket.tokens--; return true; }
return false; // drop this log notification
}
// ALSO SECURE: never emit per-item debug logs in loops; aggregate instead
logger.debug('Processed batch', { itemCount: items.length, duration: Date.now() - start });
// ALSO SECURE: cap DEBUG level to admin/operator sessions only (see SA-MCP-LOG-004)
// DETECTION (SkillAudit):
// Flag: logger.debug() inside loops that iterate variable-length collections
const debugInLoops = findDebugCallsInLoops();
if (debugInLoops.length > 0) → HIGH // SA-MCP-LOG-003
// Flag: no rate limiting on notifications/message emission
if (!hasLogNotificationRateLimit()) → HIGH // SA-MCP-LOG-003
DEBUG log floods are asymmetric DoS: The attacker calls one API endpoint (logging/setLevel) and sends infrequent tool calls. The server does all the work of generating, serializing, and transmitting thousands of notification messages per call. This asymmetry makes the attack cheap to execute and expensive to absorb, especially on serverless MCP deployments with per-message billing or memory-limited containers.
notifications/message emissions; DEBUG level generates per-item log entries in loops: log notification flood from single client degrades transport and exhausts notification queues. Detection: logger.debug inside variable-length loops without rate limiting → HIGH.SA-MCP-LOG-004: unauthenticated logging/setLevel allows any client to elevate server verbosity
The MCP specification makes logging/setLevel available to any connected client without requiring elevated privileges. In production MCP server deployments, elevating log verbosity should be an operator action, not a user action. Community MCP servers that implement logging/setLevel without an authorization check allow any authenticated client (including free-tier users, test accounts, and compromised client sessions) to set the server to DEBUG verbosity, triggering all of SA-MCP-LOG-001, -002, and -003 simultaneously.
// VULNERABLE: any authenticated client can call logging/setLevel
server.setRequestHandler('logging/setLevel', async (req, context) => {
// No authorization check — any session can set any log level
currentLogLevel = req.params.level;
return {};
// Free-tier user sets debug → receives credentials and floods transport
});
// ALSO VULNERABLE: setLevel accepted without even an active session
server.setRequestHandler('logging/setLevel', async (req) => {
// context not checked — could be called before authentication completes
globalLogLevel = req.params.level;
return {};
});
// SECURE OPTION 1: disable logging/setLevel entirely in production
server.setRequestHandler('logging/setLevel', async (req, context) => {
if (process.env.NODE_ENV === 'production') {
throw new McpError(ErrorCode.MethodNotFound,
'logging/setLevel is not available in production');
}
// Only enabled in development/staging
sessionLogLevels.set(context.sessionId, req.params.level);
return {};
});
// SECURE OPTION 2: require operator role to change log level
server.setRequestHandler('logging/setLevel', async (req, context) => {
const session = await getSession(context.sessionId);
if (!session.roles.includes('operator')) {
throw new McpError(ErrorCode.InvalidRequest,
'Insufficient permissions: logging/setLevel requires operator role');
}
// Operators can adjust verbosity; regular users cannot
sessionLogLevels.set(context.sessionId, req.params.level);
return {};
});
// SECURE OPTION 3: cap setLevel to INFO minimum in production
const LOG_LEVEL_FLOOR = process.env.NODE_ENV === 'production' ? 'info' : 'debug';
server.setRequestHandler('logging/setLevel', async (req, context) => {
const requested = req.params.level;
const effective = logLevelValue(requested) >= logLevelValue(LOG_LEVEL_FLOOR)
? requested : LOG_LEVEL_FLOOR;
sessionLogLevels.set(context.sessionId, effective);
return {};
// Client requests debug, gets info — harmless response, silent floor enforcement
});
// DETECTION (SkillAudit):
// Check logging/setLevel handler for authorization
const setLevelHandler = findRequestHandler('logging/setLevel');
if (!hasAuthorizationCheck(setLevelHandler)) → CRITICAL // SA-MCP-LOG-004
if (!hasProductionGuard(setLevelHandler)) → HIGH // SA-MCP-LOG-004
// Flag if setLevel can be called without an active authenticated session
if (setLevelBeforeAuth(setLevelHandler)) → CRITICAL // SA-MCP-LOG-004
Unauthenticated logging/setLevel is the entry point for all other SA-MCP-LOG attacks: SA-MCP-LOG-001, -002, and -003 all require the attacker to first set the log level to DEBUG. Without authorization on logging/setLevel, any session can trigger all three. The fix — adding a role check or environment guard — is a one-line change that blocks the entire SA-MCP-LOG attack family at its root.
logging/setLevel handler lacks authorization check: any client can set server verbosity to DEBUG, enabling SA-MCP-LOG-001 credential leakage, SA-MCP-LOG-002 cross-session broadcast, and SA-MCP-LOG-003 notification flood simultaneously. Detection: missing role/operator check in logging/setLevel handler → CRITICAL.How SkillAudit detects SA-MCP-LOG patterns
| Pattern | Detection method | Severity |
|---|---|---|
| SA-MCP-LOG-001 | Static: logger.debug calls with full request objects, auth headers, or env vars | CRITICAL |
| SA-MCP-LOG-002 | Static: global log level variable in logging/setLevel handler + all-sessions iteration in notification emitter | CRITICAL |
| SA-MCP-LOG-003 | Static: logger.debug inside variable-length loops; missing rate limit on notification emission | HIGH |
| SA-MCP-LOG-004 | Static: logging/setLevel handler missing authorization check or production environment guard | CRITICAL |
Run a SkillAudit scan on your MCP server’s logging implementation: Paste your GitHub URL at skillaudit.dev to get a graded security report covering all four SA-MCP-LOG patterns and 200+ additional MCP security checks.