Guide · AWS Bedrock

MCP Server Bedrock Agents — action groups, session state, trace events, agent aliases

AWS Bedrock Agents handles multi-step reasoning internally — your MCP server only needs to expose two tools: one to invoke the agent and one to retrieve its final response, or a single streaming-aware tool that drains the agent's event stream to completion. The Bedrock Agent does the orchestration loop (deciding which Lambda action groups to call, in which order, with what arguments) while the MCP client sees a single high-level tool call. This architecture makes sense when you want managed orchestration, conversation memory, and built-in knowledge base integration without writing your own agent loop. This guide covers the four operational details that matter in production: the InvokeAgent API and its mandatory streaming response model, action group Lambda signatures and the exact request/response schema Bedrock enforces, passing MCP session context through sessionState attributes, and using enableTrace to debug OrchestrationTrace events when an agent loops or fails silently. It also covers agent aliases — specifically why DRAFT must never be used in production and how alias routing config enables A/B testing between agent versions.

TL;DR

Use @aws-sdk/client-bedrock-agent-runtime and InvokeAgentCommand. Always use a published alias ID (not the literal string DRAFT) — alias IDs look like ABCDEF1234 and are created in the Bedrock console or via CreateAgentAliasCommand. The response is always a streaming event iterator — there is no non-streaming version of InvokeAgent. Drain the stream until you see an chunk event with bytes to get the final answer. Pass MCP session ID and user context through sessionState.sessionAttributes (string values only). Enable trace with enableTrace: true and log OrchestrationTrace.observation.finalResponse whenever the agent produces an unexpected response or loops.

Architecture: MCP tool wrapping a Bedrock Agent invocation

A Bedrock Agent is a managed service that runs an agentic loop: it calls a foundation model with your system prompt, decides whether to use a Lambda-backed action group or a Knowledge Base, executes the action, injects the result, and loops until the model produces a final response. From the MCP client's perspective, none of this orchestration is visible — the client calls a single MCP tool and receives a single result.

The MCP server's role in this architecture is thin: it receives the tool call, maps the MCP tool arguments to the InvokeAgent API parameters (agentId, agentAliasId, sessionId, inputText), drains the response event stream, and returns the final agent response as the MCP tool result. The MCP server does not implement any agent logic — it is a protocol adapter between the MCP client and the Bedrock Agent API.

When to use this pattern vs the Converse API loop:

CriterionBedrock Agents via MCPConverse API loop in MCP
Multi-step reasoningManaged by Bedrock Agent internallyImplemented by MCP server code
Knowledge base integrationNative, configured in Bedrock consoleMust call Retrieve API manually
Conversation memoryManaged by Bedrock Agent (DynamoDB-backed)Must be stored and replayed by MCP server
Tool/action definitionsStatic — defined in Bedrock console, not discoverable at runtimeDynamic — MCP tools/list works normally
DebuggingTrace events; CloudWatch Logs in agent's accountFull control over logging and instrumentation
LatencyHigher (Bedrock orchestration overhead per step)Lower (direct model calls)
import {
  BedrockAgentRuntimeClient,
  InvokeAgentCommand,
  type InvokeAgentCommandInput,
} from '@aws-sdk/client-bedrock-agent-runtime';

const agentClient = new BedrockAgentRuntimeClient({
  region: process.env.AWS_REGION ?? 'us-east-1',
});

// MCP tool handler: wraps Bedrock Agent invocation
async function invokeBedrockAgent(
  agentId: string,
  agentAliasId: string,  // never 'DRAFT' in production
  sessionId: string,     // stable ID for conversation continuity
  userMessage: string,
  sessionAttributes?: Record,  // string values only
): Promise {
  const input: InvokeAgentCommandInput = {
    agentId,
    agentAliasId,
    sessionId,
    inputText: userMessage,
    enableTrace: process.env.NODE_ENV !== 'production',  // always enable in dev
    sessionState: sessionAttributes
      ? { sessionAttributes }
      : undefined,
  };

  const response = await agentClient.send(new InvokeAgentCommand(input));

  // InvokeAgent ALWAYS returns a streaming response — drain the stream
  if (!response.completion) {
    throw new Error('No completion stream in InvokeAgent response');
  }

  const chunks: string[] = [];

  for await (const event of response.completion) {
    if ('chunk' in event && event.chunk?.bytes) {
      // The agent's final text response
      chunks.push(new TextDecoder().decode(event.chunk.bytes));
    }
    // 'trace' events contain orchestration trace data — see trace section
    // 'returnControl' events indicate the agent wants YOUR code to run something
    // 'files' events contain file outputs from Code Interpreter action groups
  }

  return chunks.join('');
}

The sessionId is crucial for conversation continuity. Bedrock Agents stores conversation history keyed by sessionId — if you pass the same sessionId across multiple InvokeAgent calls, the agent remembers previous turns. Each MCP client session should get a stable sessionId. The session expires after 30 minutes of inactivity by default (configurable in the agent's settings up to 24 hours). Map the MCP protocol session ID directly to the Bedrock Agent sessionId to avoid maintaining a separate mapping.

Action groups: Lambda request/response schema and validation

Bedrock Agents calls Lambda functions when the agent decides to use an action group. The Lambda function receives a structured event and must return a response in a specific format. Getting either side wrong results in the agent failing silently — it may retry, call a different action group, or produce a hallucinated response, all without a clear error to the caller.

The Lambda event from Bedrock Agents for function-type action groups has this shape:

// Lambda event from Bedrock Agents (function-type action group)
interface BedrockAgentActionGroupEvent {
  messageVersion: '1.0';
  agent: {
    name: string;
    id: string;
    alias: string;
    version: string;
  };
  inputText: string;       // the user's original message (for context)
  sessionId: string;
  actionGroup: string;     // action group name as configured in Bedrock
  function: string;        // function name within the action group
  parameters: Array<{
    name: string;
    type: 'string' | 'number' | 'integer' | 'boolean' | 'array';
    value: string;         // ALWAYS a string — parse to the declared type yourself
  }>;
  sessionAttributes: Record;
  promptSessionAttributes: Record;
}

// Lambda response to Bedrock Agents (function-type action group)
interface BedrockAgentActionGroupResponse {
  messageVersion: '1.0';
  response: {
    actionGroup: string;      // must echo back the actionGroup from the event
    function: string;         // must echo back the function from the event
    functionResponse: {
      responseState?: 'FAILURE' | 'REPROMPT';  // omit for success
      responseBody: {
        TEXT: { body: string };   // the result as a text string
      };
    };
  };
}

// Example Lambda handler with type-safe parameter parsing
export const handler = async (
  event: BedrockAgentActionGroupEvent,
): Promise => {
  // Parse parameters from string values to their declared types
  const params = Object.fromEntries(
    event.parameters.map(p => {
      let value: unknown = p.value;
      if (p.type === 'number' || p.type === 'integer') value = Number(p.value);
      if (p.type === 'boolean') value = p.value === 'true';
      if (p.type === 'array') {
        try { value = JSON.parse(p.value); } catch { value = [p.value]; }
      }
      return [p.name, value];
    }),
  );

  let body: string;
  let responseState: 'FAILURE' | 'REPROMPT' | undefined;

  try {
    const result = await dispatchAction(event.function, params, event.sessionAttributes);
    body = typeof result === 'string' ? result : JSON.stringify(result);
  } catch (err) {
    // REPROMPT tells the agent to try again with different parameters
    // FAILURE tells the agent the action group is unavailable
    body = `Error executing ${event.function}: ${(err as Error).message}`;
    responseState = 'REPROMPT';
  }

  return {
    messageVersion: '1.0',
    response: {
      actionGroup: event.actionGroup,  // must echo
      function: event.function,        // must echo
      functionResponse: {
        responseState,
        responseBody: { TEXT: { body } },
      },
    },
  };
};

async function dispatchAction(
  fn: string,
  params: Record,
  sessionAttrs: Record,
): Promise {
  switch (fn) {
    case 'search_documents':
      return searchDocuments(params.query as string, params.max_results as number ?? 5);
    case 'get_order_status':
      return getOrderStatus(params.order_id as string);
    default:
      throw new Error(`Unknown function: ${fn}`);
  }
}

Critical validation detail: Bedrock Agents sends all parameter values as strings regardless of the declared type in the action group schema. A parameter declared as number arrives as the string "42", not the number 42. Always parse parameters explicitly before use. Failing to parse numeric parameters is the most common source of NaN errors in action group Lambdas.

The responseState field controls how the agent interprets the Lambda response. Omitting it (or setting it to undefined) signals success. REPROMPT tells the agent to try calling the action group again with different parameters — use this for validation errors where the agent can fix its input. FAILURE tells the agent the action group is not available — the agent will try to complete the task without it, which typically means telling the user it cannot fulfill the request.

Session state: passing MCP context into the Bedrock Agent

Bedrock Agents supports two scopes of session state: sessionAttributes and promptSessionAttributes. Both are string-valued key-value maps, but they differ in how and where they are injected into the agent's context.

sessionAttributes are available throughout the session and are passed to every Lambda action group invocation in the event's sessionAttributes field. They are not injected into the model's prompt. Use them to pass MCP session metadata to Lambda functions — user ID, tenant ID, MCP client version, feature flags — without polluting the model's context window.

promptSessionAttributes are injected into the model's prompt at each turn via the agent's prompt template. The agent's orchestration prompt template can reference these attributes using the $prompt_session_attributes$ placeholder. Use them for context the model needs to reason about — current date, user role, active filters — but be aware they consume input tokens and must be concise.

// Passing MCP session context to Bedrock Agent via sessionState
async function invokeAgentWithContext(
  agentId: string,
  agentAliasId: string,
  mcpSessionId: string,
  userMessage: string,
  mcpContext: {
    userId: string;
    tenantId: string;
    userRole: 'admin' | 'viewer' | 'editor';
    currentTimestamp: string;
  },
): Promise {
  const response = await agentClient.send(new InvokeAgentCommand({
    agentId,
    agentAliasId,
    sessionId: mcpSessionId,     // MCP session ID maps directly to Bedrock session ID
    inputText: userMessage,
    sessionState: {
      // sessionAttributes: available in Lambda action group events
      // values MUST be strings — serialize objects to JSON if needed
      sessionAttributes: {
        userId:    mcpContext.userId,
        tenantId:  mcpContext.tenantId,
        userRole:  mcpContext.userRole,
      },
      // promptSessionAttributes: injected into model prompt via $prompt_session_attributes$
      promptSessionAttributes: {
        currentTime: mcpContext.currentTimestamp,  // e.g. '2026-09-12T14:30:00Z'
        userRole:    mcpContext.userRole,
      },
    },
  }));

  const chunks: string[] = [];
  for await (const event of response.completion!) {
    if ('chunk' in event && event.chunk?.bytes) {
      chunks.push(new TextDecoder().decode(event.chunk.bytes));
    }
  }
  return chunks.join('');
}

// In the Lambda action group: read session attributes for authorization
export const authorizedHandler = async (event: BedrockAgentActionGroupEvent) => {
  const userId  = event.sessionAttributes['userId'];
  const userRole = event.sessionAttributes['userRole'];

  // Gate sensitive operations on user role
  if (event.function === 'delete_record' && userRole !== 'admin') {
    return errorResponse(event, 'REPROMPT',
      `User ${userId} with role ${userRole} is not authorized to delete records.`);
  }

  // ... proceed with authorized action
};

Important constraint: all sessionAttributes and promptSessionAttributes values must be strings. Bedrock will reject the request with a ValidationException if you pass a number or boolean. Serialize numeric or boolean values to strings (String(42), String(true)) before setting them. For structured objects, serialize to JSON string and parse in the Lambda handler.

Trace events: debugging with OrchestrationTrace and PreProcessingTrace

When enableTrace: true is set on the InvokeAgentCommand, the response event stream includes trace events interleaved with chunk events. Each trace event contains a trace object with one of four trace types: preProcessingTrace, orchestrationTrace, postProcessingTrace, or failureTrace. These are the primary diagnostic tool when an agent produces wrong answers, loops, or fails silently.

OrchestrationTrace is the most useful in practice. It contains one of four observation types per step: modelInvocationInput (what the model was sent — the full prompt including retrieved knowledge base chunks and previous action results), modelInvocationOutput (what the model responded — the raw text before Bedrock parses it for action group calls), actionGroupInvocationInput (which action group was called and with what parameters), and actionGroupInvocationOutput (what the Lambda returned). When the agent loops without making progress, check modelInvocationOutput across turns — if the model output is identical across two turns, the agent is stuck and the Lambda response is not being parsed correctly.

import type {
  ResponseStream,
  TracePart,
  OrchestrationTrace,
  PreProcessingTrace,
} from '@aws-sdk/client-bedrock-agent-runtime';

interface AgentInvokeResult {
  finalResponse: string;
  traces: TracePart[];
}

async function invokeAgentWithTrace(
  agentId: string,
  agentAliasId: string,
  sessionId: string,
  inputText: string,
): Promise {
  const response = await agentClient.send(new InvokeAgentCommand({
    agentId,
    agentAliasId,
    sessionId,
    inputText,
    enableTrace: true,
  }));

  const chunks: string[] = [];
  const traces: TracePart[] = [];

  for await (const event of response.completion!) {
    if ('chunk' in event && event.chunk?.bytes) {
      chunks.push(new TextDecoder().decode(event.chunk.bytes));
    }

    if ('trace' in event && event.trace) {
      traces.push(event.trace);
      logTrace(event.trace);
    }
  }

  return { finalResponse: chunks.join(''), traces };
}

function logTrace(trace: TracePart): void {
  const t = trace.trace;
  if (!t) return;

  if (t.orchestrationTrace) {
    const ot: OrchestrationTrace = t.orchestrationTrace;

    if (ot.modelInvocationOutput?.parsedResponse) {
      // What the model decided to do next
      console.debug('[ORCHESTRATION] Model parsed response:', {
        actionType: ot.modelInvocationOutput.parsedResponse.isValid
          ? 'valid_action'
          : 'invalid_response',
        text: ot.modelInvocationOutput.parsedResponse.text?.slice(0, 200),
      });
    }

    if (ot.actionGroupInvocationInput) {
      console.debug('[ORCHESTRATION] Action group call:', {
        actionGroup: ot.actionGroupInvocationInput.actionGroupName,
        function:    ot.actionGroupInvocationInput.function,
        parameters:  ot.actionGroupInvocationInput.parameters,
      });
    }

    if (ot.actionGroupInvocationOutput) {
      console.debug('[ORCHESTRATION] Action group result:', {
        text: ot.actionGroupInvocationOutput.text?.slice(0, 500),
      });
    }

    if (ot.observation?.finalResponse) {
      // The agent's final answer before post-processing
      console.debug('[ORCHESTRATION] Final response:', ot.observation.finalResponse.text);
    }
  }

  if (t.failureTrace) {
    // Agent failed entirely — log the failure reason
    console.error('[FAILURE]', {
      reason: t.failureTrace.failureReason,
    });
  }

  if (t.preProcessingTrace) {
    const pt: PreProcessingTrace = t.preProcessingTrace;
    if (pt.modelInvocationOutput?.parsedResponse) {
      // Pre-processing determines whether the input is valid/safe to process
      console.debug('[PREPROCESSING] Input classification:', {
        isValid: pt.modelInvocationOutput.parsedResponse.isValid,
        rationale: pt.modelInvocationOutput.parsedResponse.rationale,
      });
    }
  }
}

Two common debugging patterns using trace: (1) Agent always returns "I cannot help with that" — check preProcessingTrace.modelInvocationOutput.parsedResponse.isValid. If it is false, the agent's pre-processing step classified the input as invalid or out of scope. Update the agent's pre-processing prompt template or its base instructions. (2) Agent calls the action group with wrong parameter values — check orchestrationTrace.actionGroupInvocationInput.parameters. Compare to the parameter types declared in the action group schema. The model infers parameter values from the input text — if the schema description is ambiguous, the model fills in wrong values.

Agent aliases: DRAFT vs published, routing config, and A/B testing

Every Bedrock Agent has a DRAFT version that represents the current, unpublished working state. The DRAFT alias resolves to a fixed alias ID: TSTALIASID. Using the DRAFT alias in production has three consequences that will cause incidents: (1) Any developer editing the agent's instructions, action groups, or knowledge base configuration immediately affects live traffic — there is no staging/production separation. (2) DRAFT does not support provisioned throughput — you are always on on-demand limits. (3) DRAFT has higher latency than published aliases because Bedrock does not cache the agent configuration.

The production workflow is: create a named alias pointing to a specific agent version. A version is a snapshot of the agent's configuration taken at a point in time. Use the Bedrock console or CreateAgentVersionCommand + CreateAgentAliasCommand to create a version and alias before deploying.

import {
  BedrockAgentClient,
  CreateAgentVersionCommand,
  CreateAgentAliasCommand,
  UpdateAgentAliasCommand,
  GetAgentAliasCommand,
} from '@aws-sdk/client-bedrock-agent';

const bedrockAgentMgmt = new BedrockAgentClient({
  region: process.env.AWS_REGION ?? 'us-east-1',
});

// Create a new agent version from the current DRAFT
async function publishAgentVersion(agentId: string, description: string) {
  const { agentVersion } = await bedrockAgentMgmt.send(
    new CreateAgentVersionCommand({ agentId, description }),
  );
  console.log('Published agent version:', agentVersion?.agentVersion);
  return agentVersion?.agentVersion;
}

// Create an alias pointing to a specific version
async function createAlias(agentId: string, aliasName: string, agentVersion: string) {
  const { agentAlias } = await bedrockAgentMgmt.send(
    new CreateAgentAliasCommand({
      agentId,
      agentAliasName: aliasName,
      routingConfiguration: [
        { agentVersion },         // single-version alias
      ],
    }),
  );
  console.log('Created alias:', agentAlias?.agentAliasId);
  return agentAlias?.agentAliasId;
}

// A/B test: route 80% of traffic to v3, 20% to v4
// Note: multi-version routing splits invocations randomly at the Bedrock level
async function createABAlias(agentId: string, versionA: string, versionB: string) {
  const { agentAlias } = await bedrockAgentMgmt.send(
    new CreateAgentAliasCommand({
      agentId,
      agentAliasName: 'production-ab-test',
      routingConfiguration: [
        { agentVersion: versionA, provisionedThroughput: undefined },
        { agentVersion: versionB, provisionedThroughput: undefined },
        // Bedrock splits traffic equally across all entries in routingConfiguration
        // To achieve 80/20, list versionA four times and versionB once
        { agentVersion: versionA },
        { agentVersion: versionA },
        { agentVersion: versionA },
      ],
    }),
  );
  return agentAlias?.agentAliasId;
}

// In your MCP server: read alias IDs from config, never hardcode
const AGENT_CONFIG = {
  agentId:      process.env.BEDROCK_AGENT_ID!,
  agentAliasId: process.env.BEDROCK_AGENT_ALIAS_ID!, // e.g. 'ABCDEF1234', never 'TSTALIASID'
};

// Validate at startup that the alias is not DRAFT
if (AGENT_CONFIG.agentAliasId === 'TSTALIASID') {
  throw new Error(
    'BEDROCK_AGENT_ALIAS_ID is set to TSTALIASID (DRAFT). ' +
    'Create a published alias and use its ID in production.',
  );
}

When a new agent version is ready, update the production alias using UpdateAgentAliasCommand to point to the new version. This is a zero-downtime operation — in-flight invocations on the old version complete normally; new invocations immediately route to the new version. Roll back by calling UpdateAgentAliasCommand again to point to the previous version. Keep the previous two versions retained until you have confirmed the new version is behaving correctly in production, using trace event data to compare response quality.

Common failure modes

SymptomCauseFix
ResourceNotFoundException on InvokeAgent with alias ID The alias was deleted, or the alias ID is from a different region or account Verify alias exists with GetAgentAliasCommand; ensure BedrockAgentRuntimeClient region matches the region where the alias was created
Agent response stream ends with no chunk event — empty string returned Pre-processing classified the input as out of scope; the agent stopped before producing a response Enable trace (enableTrace: true) and check preProcessingTrace.modelInvocationOutput.parsedResponse.isValid — if false, update the agent's pre-processing prompt or base instructions to broaden the accepted input scope
Lambda action group receives all parameters as empty strings The action group's OpenAPI or function schema has parameter names that do not match what the model was instructed to pass Check orchestrationTrace.actionGroupInvocationInput.parameters in trace events — if parameters are missing there too, the schema name mismatch is in the agent configuration; if they appear in trace but not in Lambda, check the Bedrock-to-Lambda serialization in the action group config
DependencyFailedException in agent invocation The Lambda action group returned a 5xx error, timed out, or threw an unhandled exception Check Lambda CloudWatch Logs for the specific error; ensure the Lambda timeout is longer than the slowest action (default Lambda timeout of 3s is often too short for actions that call external APIs)
Agent loops indefinitely — trace shows the same action group being called 10+ times The Lambda action group returned a REPROMPT responseState in a loop, or the model cannot parse the action group response format Inspect actionGroupInvocationOutput.text in trace — if it starts with "Error:" the model is retrying on each REPROMPT; fix the underlying Lambda error. If the output looks correct but the loop continues, the response body format may be mismatched — ensure the TEXT.body response structure is exactly as Bedrock expects