Guide · AWS Comprehend · PII Detection

AWS Comprehend PII Detection in MCP Servers — ContainsPiiEntities, Redaction, Async Jobs

MCP servers that process user-provided text — support tickets, document uploads, natural-language queries — must guard against forwarding personally identifiable information (PII) to downstream systems, logs, or AI models without consent. AWS Comprehend provides two complementary PII APIs: ContainsPiiEntities classifies a document as containing or not containing PII (fast binary decision for pre-screening), and DetectPiiEntities returns exact character spans for each PII entity found. For batch workloads, the async StartPiiEntitiesDetectionJob processes large document sets in S3 with two output modes: ONLY_REDACTION (returns a copy of the document with PII masked) and ONLY_OFFSETS (returns span coordinates without modifying the document). The redaction mode further supports two masking strategies: REDACT (replaces each span with * characters) and REPLACE_WITH_PII_ENTITY_TYPE (replaces each span with the entity type label like [NAME]). This guide covers when to use each API, how to wire PII detection into an MCP tool handler as a pre-processing gate, and how to configure the async redaction job for compliance pipelines.

TL;DR

Use ContainsPiiEntitiesCommand as a fast pre-screen (returns Labels[] with entity type + score, no spans). Use DetectPiiEntitiesCommand to get exact character spans when you need to extract, redact, or audit specific PII instances. Both are synchronous (≤5,000 bytes). For large document batches, use StartPiiEntitiesDetectionJobCommand with Mode: 'ONLY_REDACTION' and RedactionConfig.MaskMode: 'REPLACE_WITH_PII_ENTITY_TYPE' to produce compliance-safe document copies. PII detection does not require a custom model — it is a built-in Comprehend capability billed at $0.0001 per 100 characters.

PII entity types Comprehend detects

Comprehend's PII recognizer identifies 27 entity types for English, and a subset for other supported languages. Key types relevant to MCP server workloads:

TypeExamples detected
NAMEPersonal names, first + last combinations
EMAILEmail addresses in standard formats
PHONEPhone numbers (US and international formats)
ADDRESSStreet addresses, postal codes
SSNUS Social Security Numbers (NNN-NN-NNNN)
CREDIT_DEBIT_NUMBERPayment card numbers (all major networks)
BANK_ACCOUNT_NUMBERBank account numbers
IP_ADDRESSIPv4 and IPv6 addresses
URLWeb URLs that may identify individuals
USERNAMEUsernames in @mention format and similar patterns
PASSWORDCredential-like strings following "password:" patterns
AWS_ACCESS_KEYAWS access key IDs (AKIA... prefix)
AWS_SECRET_KEY40-character AWS secret access keys
DATE_TIMESpecific dates that may identify individuals
PASSPORT_NUMBERUS and international passport numbers
DRIVER_IDUS driver's license numbers

AWS_ACCESS_KEY and AWS_SECRET_KEY detection is particularly valuable for MCP servers: users sometimes paste AWS credentials into tool arguments (a common accidental credential leak). Comprehend will flag these before they hit logs or downstream APIs.

ContainsPiiEntities: fast pre-screening

import {
  ComprehendClient,
  ContainsPiiEntitiesCommand,
  LanguageCode,
} from '@aws-sdk/client-comprehend';

const client = new ComprehendClient({ region: 'us-east-1' });

interface PiiScreenResult {
  containsPii: boolean;
  detectedTypes: Array<{ type: string; score: number }>;
}

async function screenForPii(text: string): Promise<PiiScreenResult> {
  const { Labels } = await client.send(new ContainsPiiEntitiesCommand({
    Text: text,
    LanguageCode: LanguageCode.EN,
  }));

  const detected = (Labels ?? []).filter(l => (l.Score ?? 0) >= 0.80);

  return {
    containsPii: detected.length > 0,
    detectedTypes: detected.map(l => ({ type: l.Name ?? '', score: l.Score ?? 0 })),
  };
}

ContainsPiiEntities returns Labels[] with entity type names and confidence scores — it does NOT return character offsets. It answers the question "does this text contain PII?" not "where exactly is the PII?" Use it as a pre-screen gate when you only need the binary decision (e.g., before forwarding tool input to a third-party API). If you need to log or redact the specific spans, call DetectPiiEntities instead.

The same 5,000 UTF-8 byte limit applies. For longer text, split on paragraph boundaries and run ContainsPiiEntities on each chunk — if any chunk returns a positive, treat the whole document as containing PII.

DetectPiiEntities: span-level extraction

import {
  DetectPiiEntitiesCommand,
  type PiiEntity,
} from '@aws-sdk/client-comprehend';

interface RedactedResult {
  redactedText: string;
  detectedEntities: Array<{ type: string; score: number; original: string }>;
}

async function detectAndRedact(text: string, threshold = 0.80): Promise<RedactedResult> {
  const { Entities } = await client.send(new DetectPiiEntitiesCommand({
    Text: text,
    LanguageCode: LanguageCode.EN,
  }));

  const confident = (Entities ?? [])
    .filter((e): e is Required<PiiEntity> =>
      (e.Score ?? 0) >= threshold &&
      e.BeginOffset !== undefined &&
      e.EndOffset !== undefined
    )
    .sort((a, b) => a.BeginOffset - b.BeginOffset);

  // Redact spans from right to left to avoid offset drift
  let redacted = text;
  const detected: RedactedResult['detectedEntities'] = [];

  for (const entity of [...confident].reverse()) {
    const original = text.slice(entity.BeginOffset, entity.EndOffset);
    const mask = `[${entity.Type}]`;
    redacted = redacted.slice(0, entity.BeginOffset) + mask + redacted.slice(entity.EndOffset);
    detected.unshift({ type: entity.Type, score: entity.Score, original });
  }

  return { redactedText: redacted, detectedEntities: detected };
}

When building redacted text by replacing spans with masks, always process spans in reverse offset order (highest start offset first) to avoid corrupting the offset positions of earlier spans. After replacing span at offset 200–210, the string length changes — offsets 0–199 remain valid, but any forward replacement would now reference wrong positions. Sorting descending by BeginOffset and replacing right-to-left eliminates this bug.

Wiring PII detection into an MCP tool handler

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

const server = new McpServer({ name: 'document-processor', version: '1.0.0' });

// Sensitive types that block forwarding to external APIs
const BLOCKING_TYPES = new Set([
  'SSN', 'CREDIT_DEBIT_NUMBER', 'BANK_ACCOUNT_NUMBER',
  'AWS_ACCESS_KEY', 'AWS_SECRET_KEY', 'PASSWORD', 'PASSPORT_NUMBER',
]);

server.tool('process_user_document', {
  document_text: z.string().max(4000),
}, async ({ document_text }) => {
  // Step 1: PII pre-screen (fast — returns in <200ms typically)
  const screen = await screenForPii(document_text);

  if (screen.containsPii) {
    const blockingTypes = screen.detectedTypes
      .filter(t => BLOCKING_TYPES.has(t.type))
      .map(t => t.type);

    if (blockingTypes.length > 0) {
      // Hard block — do not process documents with credentials or financial PII
      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            error: 'Document contains sensitive PII that cannot be processed',
            detected_types: blockingTypes,
            action: 'Please remove sensitive information before submitting',
          }),
        }],
      };
    }

    // Soft block — redact lower-sensitivity PII and continue
    const { redactedText, detectedEntities } = await detectAndRedact(document_text);
    // Process the redacted version and note what was masked
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          processed_text: redactedText,
          pii_redacted: detectedEntities.map(e => ({ type: e.type, score: e.score })),
          note: 'PII was detected and redacted before processing',
        }),
      }],
    };
  }

  // No PII — process normally
  return {
    content: [{ type: 'text', text: JSON.stringify({ processed_text: document_text }) }],
  };
});

The two-call pattern (ContainsPiiEntities then DetectPiiEntities) optimizes cost: ContainsPiiEntities runs a cheaper classification pass first. If PII is not present (the common case for most tool inputs), you avoid the more expensive span-level detection call. If your workload is predominantly PII-heavy text (e.g., processing support tickets from an HR system), call DetectPiiEntities directly and skip the pre-screen.

Async PII redaction job for batch documents

import {
  StartPiiEntitiesDetectionJobCommand,
  DescribePiiEntitiesDetectionJobCommand,
} from '@aws-sdk/client-comprehend';

// ONLY_REDACTION mode: outputs redacted document copies to S3
const { JobId } = await client.send(new StartPiiEntitiesDetectionJobCommand({
  JobName: `pii-redact-${Date.now()}`,
  DataAccessRoleArn: 'arn:aws:iam::123456789012:role/ComprehendDataAccessRole',
  LanguageCode: LanguageCode.EN,
  Mode: 'ONLY_REDACTION',
  RedactionConfig: {
    // REPLACE_WITH_PII_ENTITY_TYPE: replaces PII with [NAME], [EMAIL], etc.
    // REDACT: replaces PII with * characters
    MaskMode: 'REPLACE_WITH_PII_ENTITY_TYPE',
    // Optional: only redact specific entity types
    // PiiEntityTypes: ['NAME', 'EMAIL', 'PHONE', 'SSN', 'CREDIT_DEBIT_NUMBER'],
    MaskCharacter: '*',  // only used when MaskMode is REDACT
  },
  InputDataConfig: {
    S3Uri: 's3://my-bucket/raw-documents/',
    InputFormat: 'ONE_DOC_PER_LINE',
  },
  OutputDataConfig: {
    S3Uri: 's3://my-bucket/redacted-documents/',
    KmsKeyId: 'arn:aws:kms:us-east-1:123456789012:key/my-key',  // optional encryption
  },
}));

REPLACE_WITH_PII_ENTITY_TYPE produces human-readable redacted text: "Contact John Smith at john@example.com" becomes "Contact [NAME] at [EMAIL]". This is better than asterisks for downstream text analysis because the type label tells you what was removed, preserving sentence structure.

ONLY_OFFSETS mode (the alternative to ONLY_REDACTION) does not modify documents — it outputs a JSON file per document listing the PII span coordinates. Use this when you need to audit what PII exists without altering the originals, or when you want to apply custom redaction logic that Comprehend's built-in modes do not support.

PiiEntityTypes filtering

By default, StartPiiEntitiesDetectionJob with ONLY_REDACTION redacts all 27 entity types. For use cases where only specific types need redaction — for example, a compliance requirement to redact financial identifiers but preserve names for customer service workflows — specify the PiiEntityTypes list:

RedactionConfig: {
  MaskMode: 'REPLACE_WITH_PII_ENTITY_TYPE',
  // Only redact financial and credential PII; preserve names, addresses
  PiiEntityTypes: [
    'SSN',
    'CREDIT_DEBIT_NUMBER',
    'BANK_ACCOUNT_NUMBER',
    'BANK_ROUTING',
    'AWS_ACCESS_KEY',
    'AWS_SECRET_KEY',
    'PASSWORD',
    'PIN',
  ],
}

The synchronous DetectPiiEntities API returns all entity types without filtering — filter client-side by checking the Type field against your allowed list. There is no server-side PiiEntityTypes filter for the synchronous API; the type filter only applies to async jobs.

Confidence scores and false positive management

Comprehend's PII detection scores represent entity-level confidence. Common false positive patterns and mitigation strategies:

For AliveMCP's monitoring configuration documents (which users sometimes paste into tool arguments), detecting and blocking AWS_ACCESS_KEY, AWS_SECRET_KEY, and PASSWORD at 0.70+ threshold protects against credential leakage to logs, while allowing IP addresses and dates through at lower confidence prevents over-redaction of legitimate technical content.