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:

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

See also

Check your MCP server for error messages that leak internal system details to the AI model.

Run a free audit → How grading works →