Guide · AWS Bedrock
MCP Server Bedrock Guardrails — content filtering, BLOCKED responses, image moderation
AWS Bedrock Guardrails let you enforce content policies — topic denial, harmful content filtering, PII redaction, and word blocklists — at the API level rather than in application code, but integrating them correctly into an MCP server requires understanding two distinct integration points and several non-obvious failure modes. The simpler path is attaching guardrailConfig directly to a ConverseCommand so Bedrock applies the guardrail automatically before returning a response; the more powerful path is calling the standalone ApplyGuardrailCommand to validate arbitrary text — including tool inputs and outputs that never touch a foundation model. This guide covers the architecture of both integration points, how to parse the assessments array from the ApplyGuardrail response for structured audit logging, how to detect and handle stopReason: "guardrail_intervened" without propagating a raw exception to MCP clients, how to enable applyGuardrailsToImages for multimodal content, and why you must pin to a numeric guardrailVersion in production rather than using DRAFT.
TL;DR
Attach guardrailConfig: { guardrailIdentifier, guardrailVersion, trace: "ENABLED" } to every ConverseCommand call and check response.stopReason === "guardrail_intervened" before accessing response.output.message — that field is empty on a blocked response. For non-Converse pipelines, call ApplyGuardrailCommand with source: "INPUT" before processing user text, parse the assessments[0].topicPolicy, contentPolicy, sensitiveInformationPolicy, and wordPolicy fields for audit logs, and return a sanitized user-facing message when action === "GUARDRAIL_INTERVENED". Pin production guardrails to a numeric version via CreateGuardrailVersionCommand — the DRAFT version changes whenever you edit guardrail configuration and can silently alter blocking behavior.
Architecture: two integration points for Guardrails in MCP servers
Bedrock Guardrails can be applied at two distinct points in an MCP tool handler's execution path. Understanding which to use — and when to use both — determines how much of your request pipeline is protected.
The first integration point is the Converse API. When you include a guardrailConfig object in a ConverseCommand request, Bedrock evaluates the guardrail against both the input messages and the model's generated output before returning the response. If the guardrail fires on the input, the model is never invoked and you save the inference cost. If it fires on the output, the response is blocked before it leaves Bedrock's infrastructure. This is the lowest-friction path: one configuration object, no additional API calls.
The second integration point is the standalone ApplyGuardrail API. This lets you apply a guardrail to any text — user-supplied tool arguments, intermediate reasoning strings, retrieved RAG chunks, third-party API responses — without invoking a foundation model at all. An MCP server that uses Bedrock Knowledge Base for retrieval and then passes chunks to a downstream service can validate those chunks with ApplyGuardrailCommand before they leave the AWS boundary.
For complete coverage, use both: ApplyGuardrailCommand with source: "INPUT" to pre-validate user input at the MCP tool boundary, then guardrailConfig on the ConverseCommand to cover the model's output. This prevents a user from crafting a prompt that passes initial validation but elicits a policy-violating model response through indirect instruction.
import {
BedrockRuntimeClient,
ConverseCommand,
ApplyGuardrailCommand,
type ConverseCommandInput,
type ApplyGuardrailCommandInput,
} from '@aws-sdk/client-bedrock-runtime';
const client = new BedrockRuntimeClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
// Minimum IAM permissions needed:
// bedrock:InvokeModel (or bedrock:Converse) for model calls
// bedrock:ApplyGuardrail on the guardrail ARN:
// arn:aws:bedrock:REGION:ACCOUNT:guardrail/GUARDRAIL_ID
const GUARDRAIL_ID = process.env.BEDROCK_GUARDRAIL_ID!; // e.g. "a1b2c3d4e5f6"
const GUARDRAIL_VERSION = process.env.BEDROCK_GUARDRAIL_VERSION ?? 'DRAFT'; // use numeric in prod
Attaching guardrailConfig to ConverseCommand
The guardrailConfig object accepts three fields: guardrailIdentifier (the short ID or full ARN), guardrailVersion (a numeric string like "1" or the literal "DRAFT"), and trace (either "ENABLED" or "DISABLED"). Always set trace: "ENABLED" in development — the trace output populates the trace field on the response and contains per-message assessment details that are essential for understanding why a guardrail fired. In production, trace adds a small amount of response payload overhead; disable it if latency is critical and you have established guardrail behavior through testing.
async function converseWithGuardrail(
modelId: string,
userMessage: string,
): Promise<{ blocked: boolean; text: string; policy?: string }> {
const input: ConverseCommandInput = {
modelId,
messages: [{ role: 'user', content: [{ text: userMessage }] }],
guardrailConfig: {
guardrailIdentifier: GUARDRAIL_ID,
guardrailVersion: GUARDRAIL_VERSION,
trace: 'ENABLED',
},
inferenceConfig: { maxTokens: 1024, temperature: 0.7 },
};
const response = await client.send(new ConverseCommand(input));
// CRITICAL: check stopReason BEFORE accessing output.message
// When guardrail fires, output.message is present but content array is empty
if (response.stopReason === 'guardrail_intervened') {
// Determine which policy triggered from the trace
const assessment = response.trace?.guardrail?.inputAssessment
?? response.trace?.guardrail?.outputAssessments?.[0];
let policy = 'content_policy';
if (assessment?.topicPolicy?.topics?.some(t => t.action === 'BLOCKED')) {
policy = 'topic_policy';
} else if (assessment?.sensitiveInformationPolicy?.piiEntities?.length) {
policy = 'pii_policy';
} else if (assessment?.wordPolicy?.customWords?.length) {
policy = 'word_policy';
}
return {
blocked: true,
text: 'Your request could not be processed due to content policy restrictions.',
policy,
};
}
const text = response.output?.message?.content
?.filter(b => 'text' in b)
.map(b => (b as { text: string }).text)
.join('') ?? '';
return { blocked: false, text };
}
ApplyGuardrail API — pre-validation and assessment logging
The ApplyGuardrailCommand validates text without invoking a foundation model. The request body requires four fields: guardrailIdentifier, guardrailVersion, source ("INPUT" for user-supplied text, "OUTPUT" for text you are about to send to a user), and content — an array of content blocks where each block is either a text object ({ text: { text: "..." } }) or an imageBlock for binary content.
The response contains two top-level fields you need to act on. The action field is either "NONE" (guardrail did not intervene) or "GUARDRAIL_INTERVENED" (at least one policy triggered). The outputs array contains the (potentially redacted) text — for PII policies, detected entities are replaced with placeholder tokens like [NAME] or [EMAIL] in the output text even when action is "NONE". The assessments array contains one entry per content block, each with sub-objects for every policy type evaluated.
The four policy sub-objects in each assessment are: topicPolicy with a topics array (each topic has name, type, and action); contentPolicy with a filters array (each filter has type such as HATE, INSULTS, SEXUAL, VIOLENCE, MISCONDUCT, PROMPT_ATTACK, a confidence level, and action); wordPolicy with customWords and managedWordLists arrays; and sensitiveInformationPolicy with piiEntities (each has type such as EMAIL, PHONE, SSN, CREDIT_DEBIT_NUMBER, and action) and regexes.
import {
ApplyGuardrailCommand,
type ApplyGuardrailCommandInput,
type GuardrailAssessment,
} from '@aws-sdk/client-bedrock-runtime';
interface GuardrailResult {
allowed: boolean;
redactedText?: string;
auditEntry: {
timestamp: string;
source: 'INPUT' | 'OUTPUT';
action: string;
topicViolations: string[];
contentViolations: Array<{ type: string; confidence: string }>;
piiDetected: string[];
wordViolations: string[];
};
}
async function applyGuardrail(
text: string,
source: 'INPUT' | 'OUTPUT',
): Promise<GuardrailResult> {
const input: ApplyGuardrailCommandInput = {
guardrailIdentifier: GUARDRAIL_ID,
guardrailVersion: GUARDRAIL_VERSION,
source,
content: [{ text: { text } }],
};
const response = await client.send(new ApplyGuardrailCommand(input));
const assessment: GuardrailAssessment = response.assessments?.[0] ?? {};
const topicViolations = (assessment.topicPolicy?.topics ?? [])
.filter(t => t.action === 'BLOCKED')
.map(t => t.name ?? 'unknown');
const contentViolations = (assessment.contentPolicy?.filters ?? [])
.filter(f => f.action === 'BLOCKED')
.map(f => ({ type: f.type ?? 'UNKNOWN', confidence: f.confidence ?? 'NONE' }));
const piiDetected = (assessment.sensitiveInformationPolicy?.piiEntities ?? [])
.filter(e => e.action === 'ANONYMIZED' || e.action === 'BLOCKED')
.map(e => e.type ?? 'UNKNOWN');
const wordViolations = [
...(assessment.wordPolicy?.customWords ?? []).filter(w => w.action === 'BLOCKED').map(w => w.match ?? ''),
...(assessment.wordPolicy?.managedWordLists ?? []).filter(w => w.action === 'BLOCKED').map(w => w.match ?? ''),
];
const auditEntry = {
timestamp: new Date().toISOString(),
source,
action: response.action ?? 'NONE',
topicViolations,
contentViolations,
piiDetected,
wordViolations,
};
// Ship to your audit log store — CloudWatch, S3, or a SIEM
console.log(JSON.stringify({ guardrail_audit: auditEntry }));
const redactedText = response.outputs?.[0]?.text ?? text;
return {
allowed: response.action !== 'GUARDRAIL_INTERVENED',
redactedText,
auditEntry,
};
}
// Usage in an MCP tool handler:
export async function handleUserQuery(rawInput: string): Promise<string> {
const check = await applyGuardrail(rawInput, 'INPUT');
if (!check.allowed) {
// Return safe user-facing message, log audit entry for compliance
return 'I cannot process this request due to content policy restrictions.';
}
// Proceed with redactedText (PII may have been replaced with tokens)
const safeInput = check.redactedText ?? rawInput;
// ... call Converse or other downstream services
return safeInput;
}
BLOCKED response handling — stopReason detection and sanitized errors
When a Converse call has guardrailConfig attached and the guardrail fires on the model's output, the response has stopReason: "guardrail_intervened" instead of the usual "end_turn", "max_tokens", or "tool_use". The output.message field is present but its content array is empty — there is no generated text to read. Attempting to access output.message.content[0].text without checking stopReason first will throw a runtime error or silently return undefined, depending on how your code destructures the response.
The correct pattern is a stopReason guard as the first operation after await client.send(). Do not rely on the presence or absence of content blocks as your guard — the Converse API specification does not guarantee content block shape when the response is blocked, and this can vary by model family. Check stopReason first, then inspect response.trace.guardrail to determine which policy fired and produce an appropriate user-facing message.
Never propagate the raw guardrail intervention as an unhandled exception to the MCP client. Clients that receive an unstructured error for a content policy violation cannot distinguish it from a transient infrastructure failure and may retry the same blocked request, generating audit log noise and incurring unnecessary cost. Return a typed error response — for example, an MCP tool result with isError: true and a message that explains the request was blocked by policy without disclosing which specific policy or why, to prevent policy enumeration attacks.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
const server = new McpServer({ name: 'bedrock-guardrail-demo', version: '1.0.0' });
server.tool(
'bedrock_generate',
'Generate text using a Bedrock model with guardrail protection',
{
prompt: z.string().min(1).max(10000),
modelId: z.string().default('us.anthropic.claude-3-5-sonnet-20241022-v2:0'),
},
async ({ prompt, modelId }) => {
// Step 1: pre-validate input before touching any model
const inputCheck = await applyGuardrail(prompt, 'INPUT');
if (!inputCheck.allowed) {
return {
isError: true,
content: [{ type: 'text', text: 'Request blocked by content policy.' }],
};
}
// Step 2: invoke Converse with guardrailConfig for output protection
const converseInput: ConverseCommandInput = {
modelId,
messages: [{ role: 'user', content: [{ text: inputCheck.redactedText ?? prompt }] }],
guardrailConfig: {
guardrailIdentifier: GUARDRAIL_ID,
guardrailVersion: GUARDRAIL_VERSION,
trace: 'ENABLED',
},
inferenceConfig: { maxTokens: 2048 },
};
const response = await client.send(new ConverseCommand(converseInput));
// Step 3: check stopReason before ANY access to output.message
if (response.stopReason === 'guardrail_intervened') {
// Classify the intervention for structured logging
const outputAssessment = response.trace?.guardrail?.outputAssessments?.[0];
const violatedPolicy = outputAssessment?.topicPolicy?.topics?.find(t => t.action === 'BLOCKED')?.name
?? outputAssessment?.contentPolicy?.filters?.find(f => f.action === 'BLOCKED')?.type
?? 'UNKNOWN';
console.log(JSON.stringify({
event: 'guardrail_output_blocked',
policy: violatedPolicy,
modelId,
timestamp: new Date().toISOString(),
}));
return {
isError: true,
content: [{
type: 'text',
text: 'The model response was blocked by content policy. Please rephrase your request.',
}],
};
}
// Safe to access content now
const text = response.output?.message?.content
?.filter((b): b is { text: string } => 'text' in b)
.map(b => b.text)
.join('') ?? '';
return { content: [{ type: 'text', text }] };
},
);
Image moderation with applyGuardrailsToImages
Multimodal MCP tools that accept image inputs from users — screenshots, document scans, photos — require image-level content filtering. Bedrock Guardrails support image moderation through the applyGuardrailsToImages flag set to true on ImageBlock objects within the content array of a Converse message. This flag is distinct from the guardrailConfig on the request; it is set per-image inside the message body.
Supported image formats for guardrail evaluation are JPEG, PNG, GIF, and WEBP. Images must be provided as base64-encoded bytes in the source.bytes field of the ImageBlock, not as S3 URIs — S3 URI sources are supported by some models for inference but the guardrail image scan requires inline bytes. Maximum image size for guardrail evaluation is 3.75 MB after base64 decoding.
Image-specific assessment categories in the contentPolicy.filters array differ from text content categories. For images, the possible type values are HATE, INSULTS, SEXUAL, and VIOLENCE — the text-only categories MISCONDUCT and PROMPT_ATTACK do not apply to image content. Each image filter entry includes a confidence field (NONE, LOW, MEDIUM, HIGH) reflecting the detection certainty, and your guardrail configuration determines the minimum confidence level that triggers blocking.
import { readFileSync } from 'fs';
import type { ImageBlock } from '@aws-sdk/client-bedrock-runtime';
async function converseWithImageGuardrail(
imageBuffer: Buffer,
imageFormat: 'jpeg' | 'png' | 'gif' | 'webp',
textPrompt: string,
modelId = 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
): Promise<{ blocked: boolean; text?: string; blockedCategory?: string }> {
const imageBlock: ImageBlock = {
format: imageFormat,
source: { bytes: imageBuffer },
// Enable guardrail evaluation for this specific image block
// Without this flag, guardrailConfig on the request does NOT scan images
applyGuardrailsToImages: true,
};
const response = await client.send(new ConverseCommand({
modelId,
messages: [{
role: 'user',
content: [
{ image: imageBlock },
{ text: textPrompt },
],
}],
guardrailConfig: {
guardrailIdentifier: GUARDRAIL_ID,
guardrailVersion: GUARDRAIL_VERSION,
trace: 'ENABLED',
},
inferenceConfig: { maxTokens: 1024 },
}));
if (response.stopReason === 'guardrail_intervened') {
// Image assessments appear in inputAssessment, not outputAssessments
const inputAssessment = response.trace?.guardrail?.inputAssessment;
const imageFilter = inputAssessment?.contentPolicy?.filters
?.find(f => f.action === 'BLOCKED');
return {
blocked: true,
blockedCategory: imageFilter?.type ?? 'IMAGE_CONTENT',
};
}
const text = response.output?.message?.content
?.filter((b): b is { text: string } => 'text' in b)
.map(b => b.text)
.join('') ?? '';
return { blocked: false, text };
}
// For standalone image validation without model invocation:
async function validateImageWithGuardrail(imageBuffer: Buffer, format: string) {
const response = await client.send(new ApplyGuardrailCommand({
guardrailIdentifier: GUARDRAIL_ID,
guardrailVersion: GUARDRAIL_VERSION,
source: 'INPUT',
content: [{
image: {
format: format as 'jpeg' | 'png' | 'gif' | 'webp',
source: { bytes: imageBuffer },
},
}],
}));
return {
allowed: response.action !== 'GUARDRAIL_INTERVENED',
assessment: response.assessments?.[0]?.contentPolicy?.filters ?? [],
};
}
Guardrail versions — DRAFT vs pinned numeric versions
Every Bedrock Guardrail has a DRAFT version that reflects the current live configuration and a set of immutable numeric versions (1, 2, 3, ...) created by calling CreateGuardrailVersionCommand. The DRAFT version is appropriate for development and testing — it lets you iterate on policy configuration without creating a new version for every change. In production, you must never use DRAFT.
The reason is behavioral consistency. When someone edits a guardrail's topic policies, content filter thresholds, or word lists in the AWS console or via API, those changes take effect on DRAFT immediately. Any in-flight production traffic using DRAFT will silently start applying the new policy without a deployment. This can cause previously-passing requests to be blocked, or previously-blocked content to pass through, with no corresponding change to your MCP server's code or configuration.
Numeric versions are immutable snapshots. Once created, version "3" always represents exactly the policy configuration at the moment CreateGuardrailVersionCommand was called. Your deployment pipeline should create a new numeric version as part of every guardrail change, validate the new version against a test suite of known-pass and known-block inputs, and then promote the numeric version string into your production environment variables. Store the guardrail version alongside the guardrail ID in your infrastructure configuration — treat a version bump as a breaking change that requires the same review process as a code deployment.
import {
BedrockClient,
CreateGuardrailVersionCommand,
type CreateGuardrailVersionCommandInput,
} from '@aws-sdk/client-bedrock';
// Note: guardrail management uses BedrockClient (control plane),
// not BedrockRuntimeClient (data plane)
const bedrockControlPlane = new BedrockClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
async function publishGuardrailVersion(
guardrailId: string,
description: string,
): Promise<{ version: string; guardrailArn: string }> {
const input: CreateGuardrailVersionCommandInput = {
guardrailIdentifier: guardrailId,
description, // e.g. "v1.3.0 — added finance topic block, tightened PII thresholds"
// clientRequestToken is optional but recommended for idempotency
clientRequestToken: `deploy-${Date.now()}`,
};
const response = await bedrockControlPlane.send(
new CreateGuardrailVersionCommand(input),
);
console.log(`Published guardrail version ${response.version}: ${response.guardrailArn}`);
// Store response.version in your config store / Secrets Manager / SSM Parameter Store
// Then re-deploy your MCP server with BEDROCK_GUARDRAIL_VERSION=response.version
return {
version: response.version!,
guardrailArn: response.guardrailArn!,
};
}
// Validate a version before promoting to production:
async function validateGuardrailVersion(
guardrailId: string,
version: string,
testCases: Array<{ input: string; expectBlocked: boolean }>,
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
for (const tc of testCases) {
const result = await client.send(new ApplyGuardrailCommand({
guardrailIdentifier: guardrailId,
guardrailVersion: version,
source: 'INPUT',
content: [{ text: { text: tc.input } }],
}));
const blocked = result.action === 'GUARDRAIL_INTERVENED';
if (blocked !== tc.expectBlocked) {
failures.push(
`Input "${tc.input.slice(0, 40)}..." expected blocked=${tc.expectBlocked}, got blocked=${blocked}`,
);
}
}
return { passed: failures.length === 0, failures };
}
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
output.message.content is empty, no error thrown |
stopReason is "guardrail_intervened" but code reads content without checking stopReason first |
Always gate content access on response.stopReason !== 'guardrail_intervened' before reading output.message.content |
ValidationException: Guardrail version DRAFT is not allowed for production |
Some Bedrock regions enforce numeric-version-only access for guardrails via SCP or resource policy | Run CreateGuardrailVersionCommand and set guardrailVersion to the returned numeric string in all non-development environments |
AccessDeniedException on ApplyGuardrailCommand but not on ConverseCommand |
IAM role has bedrock:InvokeModel but is missing the separate bedrock:ApplyGuardrail action, which is a distinct permission |
Add bedrock:ApplyGuardrail with resource arn:aws:bedrock:REGION:ACCOUNT:guardrail/ID to the execution role policy |
| Image content passes guardrail despite containing policy-violating material | applyGuardrailsToImages flag omitted from the ImageBlock — guardrailConfig on the request does not automatically scan image blocks |
Set applyGuardrailsToImages: true on every ImageBlock in the message content array; it is opt-in per image, not inherited from request-level config |
| Guardrail silently changes blocking behavior in production with no code deployment | Production environment using guardrailVersion: "DRAFT"; a policy edit in the console took effect immediately |
Pin to a numeric version via CreateGuardrailVersionCommand; treat version bumps as deployments with validation test suites and approval gates |