Topic: mcp server error message information disclosure
MCP server error message information disclosure — stack traces and internal paths in tool responses
When an MCP server tool handler throws an unhandled exception, the default behavior in most frameworks is to return the error message and stack trace to the caller — in this case, the AI model. A stack trace contains file system paths, function names, line numbers, and sometimes variable values. A SQL error message contains table names, column names, and query fragments. An authentication error can reveal the exact credential format that failed. These details are reconnaissance information: an attacker who can influence the AI's tool arguments (via prompt injection) can systematically elicit error messages to map the server's internal structure. They can also be directly exfiltrated — the AI model may relay the stack trace verbatim in its response to the user.
Why error disclosure matters in the MCP context
In a traditional web application, error messages are disclosed to a browser — an endpoint that renders HTML for a human. If a stack trace leaks, a human attacker reads it. In an MCP server, error messages are disclosed to an AI model — an endpoint that may relay the information verbatim to the user, store it in context for subsequent reasoning, or have it used by a prompt injection attack that is specifically probing for reconnaissance information.
A prompt injection attack that causes a tool to error in specific ways can extract:
- File system structure of the server (from stack trace file paths)
- Database schema (from SQL error messages referencing table and column names)
- Environment variable names that exist (from "environment variable FOO not found" error messages)
- Internal service architecture (from connection error messages with internal hostnames or IPs)
- Authentication token format (from "invalid token: expected format Bearer ..." error messages)
Four error handling patterns for MCP servers
1. Top-level try/catch with correlation ID
import crypto from 'crypto'
// VULNERABLE: unhandled exception leaks stack trace to AI
server.tool('fetchUserData', z.object({ userId: z.string() }), async ({ userId }) => {
const user = await db.users.findUniqueOrThrow({ where: { id: userId } })
// If findUniqueOrThrow throws (user not found), the raw Prisma error
// including the SQL query fragment is returned to the AI model
return user
})
// SAFE: catch-all with correlation ID and internal logging
server.tool('fetchUserData', z.object({ userId: z.string() }), async ({ userId }) => {
try {
const user = await db.users.findUnique({ where: { id: userId } })
if (!user) return { error: 'User not found' }
return { id: user.id, displayName: user.displayName, email: user.email }
} catch (err) {
const correlationId = crypto.randomUUID()
// Log full error internally with correlation ID
console.error(JSON.stringify({
event: 'tool_error',
tool: 'fetchUserData',
correlationId,
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
timestamp: new Date().toISOString(),
}))
// Return only a generic message and the correlation ID
return { error: `Internal error (ref: ${correlationId})` }
}
})
2. Distinguish validation from internal errors
// Validation errors (safe to surface): the AI can use them to correct its call
// Internal errors (unsafe to surface): log internally, return correlation ID
class ValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'ValidationError'
}
}
server.tool('createOrder', z.object({
customerId: z.string(),
items: z.array(z.object({ productId: z.string(), quantity: z.number().int().positive() })),
}), async ({ customerId, items }) => {
try {
// Validation errors: throw ValidationError — these surface to AI
if (items.length === 0) throw new ValidationError('items must not be empty')
if (items.length > 100) throw new ValidationError('items must not exceed 100 per order')
// Internal operations: any thrown error here is an internal error
const order = await db.orders.create({ data: { customerId, items } })
return { orderId: order.id, status: order.status }
} catch (err) {
if (err instanceof ValidationError) {
// Safe to return validation message verbatim
return { error: err.message }
}
// All other errors: internal — log and return correlation ID only
const correlationId = crypto.randomUUID()
console.error({ event: 'tool_error', tool: 'createOrder', correlationId, err })
return { error: `Failed to create order (ref: ${correlationId})` }
}
})
3. Database error sanitization
// Prisma, pg, MySQL2 etc. all include the full SQL query in error messages.
// These must NEVER be returned to the AI model.
// Pattern: map known ORM error codes to safe messages
function sanitizeDatabaseError(err: unknown): string {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
switch (err.code) {
case 'P2025': return 'Record not found'
case 'P2002': return 'A record with that value already exists'
case 'P2003': return 'Related record not found'
default: return 'Database error'
}
}
// For unknown errors, return a generic message (never err.message)
return 'Database operation failed'
}
server.tool('deleteRecord', z.object({ id: z.string() }), async ({ id }) => {
try {
await db.records.delete({ where: { id } })
return { deleted: true }
} catch (err) {
if (isPrismaError(err)) {
// Safe: mapped Prisma code → generic message (no SQL, no table names)
return { error: sanitizeDatabaseError(err) }
}
// Unknown errors: internal
const correlationId = crypto.randomUUID()
console.error({ event: 'tool_error', correlationId, err })
return { error: `Delete failed (ref: ${correlationId})` }
}
})
4. Structured error logging for incident response
// The correlation ID in the response lets you look up the full error context
// Log everything you'd need for forensics — just not to the AI caller
interface ToolErrorEvent {
event: 'tool_error'
correlationId: string
toolName: string
argumentSummary: Record // safe summary, not raw values
errorName: string
errorMessage: string
stack: string
timestamp: string
sessionId?: string
}
function logToolError(
toolName: string,
args: Record,
err: Error,
sessionId?: string,
): string {
const correlationId = crypto.randomUUID()
const event: ToolErrorEvent = {
event: 'tool_error',
correlationId,
toolName,
// Summarize arg types and lengths, not values (may contain secrets)
argumentSummary: Object.fromEntries(
Object.entries(args).map(([k, v]) => [
k,
`${typeof v}(${String(v).length} chars)`,
])
),
errorName: err.name,
errorMessage: err.message,
stack: err.stack ?? '',
timestamp: new Date().toISOString(),
sessionId,
}
console.error(JSON.stringify(event))
return correlationId
}
What SkillAudit checks
- Unhandled promise rejections or thrown errors in tool handlers that expose raw
error.messageorerror.stack— HIGH; if the MCP framework's default error serializer includes the full exception, internal system details reach the AI model - Direct string interpolation of database errors into tool responses — HIGH; SQL error messages contain table names, column names, and query fragments — all reconnaissance-useful internal schema information
- Authentication error messages that reveal the expected credential format — WARN; "invalid token: expected Bearer {JWT}" tells an attacker the token format even when the token itself is rejected
- File system paths in error responses — WARN; absolute paths reveal server directory structure and may reveal deployment environment details
- Missing top-level try/catch in tool handlers that call network or database operations — WARN; any I/O operation can throw; without a catch, the MCP framework default error handler is the last line of defense
See also
- MCP server audit logging — structured internal logging so correlation IDs are findable post-incident
- MCP server credential exposure — credentials in error messages are a specific sub-case of this class
- MCP server prompt injection — the attack vector that turns error disclosure into an active reconnaissance tool
- Anatomy of a credential leak — the credential-in-error-message pattern from the 101-server corpus
Check your MCP server for error messages that leak internal system details to the AI model.
Run a free audit → How grading works →