MCP Server Security — Error Handling
MCP server error code information disclosure — stack traces, SQL errors, and timing oracles
Error responses are one of the most common sources of unintended information disclosure in MCP servers. Raw database errors expose table names and column names. Stack traces expose file paths, dependency versions, and internal function call chains. The choice between 404 and 403 tells an attacker whether a resource exists. Timing differences between "user not found" and "wrong password" enable user enumeration without any status code difference at all. This page covers the four patterns and the Node.js fixes for each.
Pattern 1: Stack traces in error responses expose server internals
Node.js Error objects carry a stack property containing the full call stack with file paths and line numbers. When a tool handler throws and the server returns err.stack or err.message (which in some libraries includes partial stack info), the client learns the absolute file path of the server code, the dependency tree structure, and sometimes the framework version. A skilled attacker uses this to identify known vulnerabilities in the specific versions running in production.
// VULNERABLE: raw error exposed in MCP tool response
server.tool('get-user-data', async (args) => {
try {
return await fetchUser(args.userId);
} catch (err) {
// err.stack: "TypeError: Cannot read properties of undefined (reading 'id')\n at fetchUser (/app/src/tools/user.js:42:18)\n at ..."
return { content: [{ type: 'text', text: err.stack }] };
}
});
// FIXED: log internally, return opaque error ID
const { randomUUID } = require('crypto');
server.tool('get-user-data', async (args) => {
try {
return await fetchUser(args.userId);
} catch (err) {
const errorId = randomUUID();
logger.error({ errorId, err, args }, 'tool handler failed');
// Return only the error ID — support can look up the full trace
return {
content: [{ type: 'text', text: `Tool error (ref: ${errorId}). Contact support if this persists.` }],
isError: true,
};
}
});
Pattern 2: Database error messages reveal schema structure
SQL errors are detailed by design — the database engine reports exactly what went wrong so developers can debug queries. Column names, table names, constraint names, and index names all appear in raw error messages. Returning err.message from a database catch block leaks this schema information to any caller who can trigger a DB error — which is often as simple as sending an out-of-range value or violating a foreign key constraint.
// VULNERABLE: DB error forwarded to client
app.post('/mcp/tools/update-profile', async (req, res) => {
try {
await db.query('UPDATE users SET role = $1 WHERE id = $2', [req.body.role, req.user.id]);
res.json({ ok: true });
} catch (err) {
// err.message: "invalid input value for enum user_role_enum: \"superadmin\"\n TABLE: users\n COLUMN: role\n CONSTRAINT: users_role_check"
// This reveals: table name (users), column name (role), enum type name (user_role_enum), constraint name
res.status(500).json({ error: err.message });
}
});
// FIXED: classify DB errors server-side, return generic messages
const { DatabaseError } = require('pg');
function classifyDbError(err) {
if (err instanceof DatabaseError) {
// Map PostgreSQL error codes to generic messages
// https://www.postgresql.org/docs/current/errcodes-appendix.html
const pgCode = err.code;
if (pgCode === '23505') return { status: 409, message: 'Conflict: duplicate value' };
if (pgCode === '23503') return { status: 400, message: 'Invalid reference' };
if (pgCode === '22P02') return { status: 400, message: 'Invalid input format' };
if (pgCode === '23514') return { status: 400, message: 'Value not allowed' };
return { status: 500, message: 'Database error' };
}
return { status: 500, message: 'Internal server error' };
}
app.post('/mcp/tools/update-profile', async (req, res) => {
try {
await db.query('UPDATE users SET role = $1 WHERE id = $2', [req.body.role, req.user.id]);
res.json({ ok: true });
} catch (err) {
const { status, message } = classifyDbError(err);
logger.error({ err, userId: req.user.id }, 'profile update failed');
res.status(status).json({ error: message });
}
});
Pattern 3: 404 vs 403 user enumeration
When a tool returns HTTP 404 for resources that don't exist and HTTP 403 for resources the caller isn't authorized to access, the status code difference reveals resource existence. An attacker probing user IDs, API keys, or resource paths can enumerate which IDs exist by distinguishing 404 ("doesn't exist") from 403 ("exists but you can't see it"). For high-value resources (user IDs, subscription IDs, API keys), the existence of the resource itself may be sensitive.
// VULNERABLE: status code reveals existence
app.get('/mcp/tools/get-audit/:auditId', async (req, res) => {
const audit = await db.audits.findById(req.params.auditId);
if (!audit) {
return res.status(404).json({ error: 'Not found' }); // reveals: does not exist
}
if (audit.userId !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' }); // reveals: exists, just not yours
}
res.json(audit);
});
// FIXED: return 404 for both "doesn't exist" and "exists but not yours"
app.get('/mcp/tools/get-audit/:auditId', async (req, res) => {
const audit = await db.audits.findById(req.params.auditId);
// Return 404 if not found OR not owned — caller cannot distinguish the two cases
if (!audit || audit.userId !== req.user.id) {
return res.status(404).json({ error: 'Not found' });
}
res.json(audit);
});
// Note: This pattern trades debuggability for confidentiality.
// For admin endpoints where resource existence is not sensitive,
// 403 is fine and more informative.
Pattern 4: Timing oracle — response time reveals user existence
Authentication endpoints that look up a user and then hash a password reveal user existence through response timing even when they return the same error message for both "user not found" and "wrong password." Looking up a non-existent user takes microseconds (no hash needed). Looking up a real user takes 60–200ms (bcrypt/Argon2 hash comparison). An attacker measuring response times can enumerate valid email addresses with high confidence even without observing the response body.
// VULNERABLE: timing difference reveals user existence
async function authenticate(email, password) {
const user = await db.users.findByEmail(email);
if (!user) {
// Returns immediately — no hash computation
throw new Error('Invalid credentials');
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
// Returns after ~100ms (bcrypt) — user existed
throw new Error('Invalid credentials');
}
return user;
}
// FIXED: always perform the hash operation regardless of user existence
const DUMMY_HASH = '$2b$12$invalidhashusedfortimingprotectiononly........';
async function authenticate(email, password) {
const user = await db.users.findByEmail(email);
// Always run bcrypt.compare — use a dummy hash if user not found.
// This ensures consistent ~100ms response time regardless of user existence.
const hashToCompare = user ? user.passwordHash : DUMMY_HASH;
const valid = await bcrypt.compare(password, hashToCompare);
if (!user || !valid) {
// Same error message AND same response time for both cases
throw new Error('Invalid credentials');
}
return user;
}
// Also: add jitter to prevent timing analysis across many parallel requests
async function authenticateWithJitter(email, password) {
const [result] = await Promise.all([
authenticate(email, password),
// Random jitter 0-50ms to increase measurement noise
new Promise(resolve => setTimeout(resolve, Math.random() * 50)),
]);
return result;
}
SkillAudit findings
The following findings appear in SkillAudit audit reports for MCP servers with information disclosure in error responses:
CRITICAL Stack traces returned in tool error responses. The tool handler returns err.stack or err.message (which includes path information) directly to the MCP client. This exposes the server's absolute file path structure, dependency names and versions, and internal function call chains. Attackers use this to identify known CVEs in the specific library versions running in production.
CRITICAL Raw database error messages forwarded to clients. SQL or ORM error messages are returned directly in API responses. These messages contain table names, column names, constraint names, enum type names, and sometimes partial query fragments. An attacker can reconstruct the database schema by triggering targeted errors with out-of-range inputs, type mismatches, and foreign key violations.
HIGH HTTP 403 returned for existing-but-unauthorized resources — resource enumeration enabled. The server returns 403 when a resource exists but the caller cannot access it, and 404 when it genuinely doesn't exist. This allows an attacker to enumerate which resource IDs exist by observing whether they receive 403 or 404. Use 404 consistently when resource existence itself is sensitive.
HIGH Authentication timing oracle — response time reveals user existence. The authentication handler returns immediately for unknown users and after a bcrypt/Argon2 hash comparison for known users. An attacker measuring response times can enumerate valid accounts without observing response bodies. Fix by always performing the hash operation using a dummy hash for unknown users.
MEDIUM Verbose error messages in tool responses expose internal state. Error messages include implementation details like queue names, internal API endpoints, or operation names that are not relevant to the caller. These details help attackers map the internal architecture of the MCP server and its downstream dependencies.
Paste a GitHub URL at skillaudit.dev to get a graded report card.