Data Exposure·OWASP API·PII Protection

MCP server excessive data exposure: returning more than the tool call requires

Excessive data exposure occurs when an MCP tool response includes fields the caller did not request and should not receive. The most common cause: the tool handler returns the full database row or ORM entity rather than a projected response shape. Every caller with get_user access receives password_hash, ssn, api_key, and every other column — regardless of what they actually needed.

Why MCP tools are especially prone to excessive data exposure

In a web API, a developer writing a REST endpoint typically builds a response DTO — they think about what fields the client needs. In MCP tool handlers, the pattern is more often to return whatever the database query returns, because the "client" is an AI agent that seems like it can filter what it uses. This is the wrong mental model. Every field returned by the tool is visible to the agent, logged in tool call records, and available to any prompt injection that captures the tool response. An agent that calls get_user and receives the full user row — including password_hash and reset_token — has become a credential exfiltration vector for any prompt injection attack that follows.

The OWASP API Security Top 10 (2023) lists Excessive Data Exposure as API3:2023, specifically flagging the pattern of relying on clients to filter sensitive fields rather than controlling what the server sends. MCP servers have the same problem with additional risk: the "client" is an AI agent operating autonomously, and tool responses may be included verbatim in subsequent prompts.

Pattern 1: field-level minimization with Zod transform

Define the response shape as a Zod schema and use .transform() to pick only the needed fields before the data leaves the handler. The Zod schema is both documentation and enforcement:

import { z } from 'zod';

const UserResponseSchema = z.object({
  id: z.string().uuid(),
  email: z.string().email(),
  display_name: z.string(),
  created_at: z.string().datetime(),
  // Deliberately excludes: password_hash, reset_token, ssn, api_key, mfa_secret
});

server.setRequestHandler(CallToolRequestSchema, async (req) => {
  if (req.params.name === 'get_user') {
    const row = await db.query('SELECT * FROM users WHERE id = $1', [id]);
    // parse() strips any field not in the schema
    const safe = UserResponseSchema.parse(row);
    return { content: [{ type: 'text', text: JSON.stringify(safe) }] };
  }
});

The critical detail: use .parse() not .passthrough(). Zod's default mode strips unknown keys. Any field added to the database that isn't explicitly in the schema never reaches the caller — the schema is an allow-list, not a block-list.

Pattern 2: response DTO separation

Never return ORM entities directly. Define separate response types for each tool that contain only the fields that tool's callers should see. A single database table may back multiple tools with different response shapes:

// The full entity — never returned directly
interface UserEntity {
  id: string;
  email: string;
  display_name: string;
  password_hash: string;
  reset_token: string | null;
  ssn_encrypted: string | null;
  api_key: string;
  mfa_secret: string;
}

// Tool: get_user (display context)
interface GetUserResponse {
  id: string;
  email: string;
  display_name: string;
}

// Tool: get_user_admin (admin context, requires elevated scope)
interface GetUserAdminResponse {
  id: string;
  email: string;
  display_name: string;
  created_at: string;
  last_login_at: string;
  // Still excludes: password_hash, ssn_encrypted, mfa_secret
}

function toGetUserResponse(entity: UserEntity): GetUserResponse {
  return { id: entity.id, email: entity.email, display_name: entity.display_name };
}

The mapping function toGetUserResponse is the only place where entity fields are explicitly selected. Adding a new column to the users table does not automatically expose it — the response type must be intentionally extended.

Pattern 3: PII/PHI/PCI classification tagging

Tag sensitive fields in your database schema and type definitions with classification markers. Use these markers to enforce that classified fields do not appear in tool response types:

// Custom type brands for classification
type PII<T> = T & { __pii: true };
type PHI<T> = T & { __phi: true };
type PCI<T> = T & { __pci: true };

interface UserEntity {
  id: string;
  email: PII<string>;
  display_name: string;
  password_hash: PII<string>;
  ssn: PII<string> | null;
  card_last4: PCI<string> | null;
  diagnosis_code: PHI<string> | null;
}

// TypeScript will error if a PII/PHI/PCI field is assigned to an unbranded type
// Use a lint rule or type test to enforce that response DTOs contain no branded fields

Classification tagging makes data sensitivity visible at the type level, enabling static analysis (SkillAudit scans for PII/PHI/PCI branded type leakage into tool response schemas) and code review (reviewers can see at a glance whether a PR introduces classified field exposure).

Pattern 4: log scrubbing for tool responses

Even if your tool response is correctly minimized, excessive data exposure can occur through logging. Tool call logging must never include raw response payloads:

const RESPONSE_LOG_ALLOWLIST = new Set(['id', 'status', 'count', 'type']);

function logToolResponse(toolName: string, response: unknown) {
  if (typeof response !== 'object' || response === null) return;
  const scrubbed: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(response)) {
    scrubbed[key] = RESPONSE_LOG_ALLOWLIST.has(key) ? value : '[redacted]';
  }
  logger.info('tool_response', { tool: toolName, response: scrubbed });
}

Log the shape of the response (which fields exist, how many items were returned) rather than the values. This gives you observability without creating a secondary log-based data exfiltration channel. The security logging post covers the full structured audit log pattern.

Pattern 5: test-time response shape auditing

Use snapshot tests to lock the shape of each tool's response and detect when new fields appear:

describe('get_user response shape', () => {
  it('contains only expected fields', async () => {
    const result = await callTool('get_user', { id: TEST_USER_ID });
    const data = JSON.parse(result.content[0].text);
    // Snapshot will fail if a new field appears in the response
    expect(Object.keys(data).sort()).toMatchSnapshot();
    // Explicit blocklist for critical fields
    expect(data).not.toHaveProperty('password_hash');
    expect(data).not.toHaveProperty('api_key');
    expect(data).not.toHaveProperty('reset_token');
    expect(data).not.toHaveProperty('mfa_secret');
    expect(data).not.toHaveProperty('ssn');
  });
});

The snapshot test on Object.keys means that any new field added to the response — whether intentional or accidental — will cause the test to fail and require an explicit snapshot update. New fields cannot silently enter the response shape.

What SkillAudit checks for excessive data exposure

SkillAudit's excessive data exposure scan inspects tool handler return values for patterns that indicate over-exposure:

Findings map to the Data Handling sub-score in a SkillAudit report. Servers returning PII, PHI, or PCI data without explicit field minimization receive a HIGH finding that blocks an A grade. See the scorecard methodology for how data handling sub-scores affect the overall letter grade.

Scan your MCP server for excessive data exposure

SkillAudit inspects tool response shapes for direct entity returns, sensitive field names, and missing response projections. Get your grade and a prioritized fix list in under 2 minutes.

Run free scan →